diff --git a/Dockerfile b/Dockerfile index 80416f4..a2efafc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.15 AS khelm +FROM alpine:3.16 AS khelm RUN apk update --no-cache RUN mkdir /helm && chown root:nobody /helm && chmod 1777 /helm ENV HELM_REPOSITORY_CONFIG=/helm/repository/repositories.yaml diff --git a/README.md b/README.md index 86ab958..01001b2 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Since [kpt](https://github.com/GoogleContainerTools/kpt) is [published](https:// * Automatically fetches and updates required repository index files when needed * Allows to automatically reload dependencies when lock file is out of sync * Allows to use any repository without registering it in repositories.yaml +* Allows to use a Helm chart from a remote git repository * Allows to exclude certain resources from the Helm chart output * Allows to enforce namespace-scoped resources within the template output * Allows to enforce a namespace on all resources @@ -200,10 +201,17 @@ khelm template cert-manager --version=0.9.x --repo=https://charts.jetstack.io _For all available options see the [table](#configuration-options) below._ #### Docker usage example + +Generate a manifest from a chart: ```sh docker run mgoltzsche/khelm:latest template cert-manager --version=0.9.x --repo=https://charts.jetstack.io ``` +Generate a manifest from a chart within a git repository: +```sh +docker run mgoltzsche/khelm:latest template cert-manager --repo=git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2 +``` + ### Go API The khelm Go API `github.com/mgoltzsche/khelm/v2/pkg/helm` provides a simple templating interface on top of the Helm Go API. @@ -245,8 +253,9 @@ It exposes a `Helm` struct that provides a `Render()` function that returns the | `outputPathMapping[].selectors[].kind` | | Selects resources by kind. | | `outputPathMapping[].selectors[].namespace` | | Selects resources by namespace. | | `outputPathMapping[].selectors[].name` | | Selects resources by name. | -| | `--output-replace` | If enabled replace the output directory or file (CLI-only). | -| | `--trust-any-repo` | If enabled repositories that are not registered within `repositories.yaml` can be used as well (env var `KHELM_TRUST_ANY_REPO`). Within the kpt function this behaviour can be disabled by mounting `/helm/repository/repositories.yaml` or disabling network access. | +| | `--output-replace` | If enabled, replace the output directory or file (CLI-only). | +| | `--trust-any-repo` | If enabled, repositories that are not registered within `repositories.yaml` can be used as well (env var `KHELM_TRUST_ANY_REPO`). Within the kpt function this behaviour can be disabled by mounting `/helm/repository/repositories.yaml` or disabling network access. | +| | `--enable-git-getter` | If enabled, support helm repository URLs with the git+https scheme (env var `KHELM_ENABLE_GIT_GETTER`). | | `debug` | `--debug` | Enables debug log and provides a stack trace on error. | ### Repository configuration @@ -257,6 +266,22 @@ When running khelm as kpt function or within a container the `repositories.yaml` Unlike Helm khelm allows usage of any repository when `repositories.yaml` is not present or `--trust-any-repo` (env var `KHELM_TRUST_ANY_REPO`) is enabled. +#### Git URLs as Helm repositories + +Helm charts can be fetched from git repositories by letting the Helm repository URL point to the chart's parent directory using the URL scheme `git+https` or `git+ssh`. +The path within the git repository URL and the repository part of the URL must be separated by `@`. +The `ref` parameter can be used to specify the git tag. + +The following example points to an old version of cert-manager using a git URL: +``` +git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2 +``` + +To enable this feature, set the `--enable-git-getter` option or the corresponding environment variable: `KHELM_ENABLE_GIT_GETTER=true`. + +This feature is meant to be compatible with Helm's [helm-git](https://github.com/aslafy-z/helm-git#usage) plugin (but is reimplemented in Go). +However currently khelm does not support `sparse` git checkouts (due to [lack of support in go-git](https://github.com/go-git/go-git/issues/90)). + ## Helm support * Helm 2 is supported by the `v1` module version. diff --git a/cmd/khelm/root.go b/cmd/khelm/root.go index 6a1a68c..05df8f7 100644 --- a/cmd/khelm/root.go +++ b/cmd/khelm/root.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "fmt" "io" "log" @@ -10,13 +11,16 @@ import ( "strconv" "strings" + "github.com/mgoltzsche/khelm/v2/pkg/getter/git" "github.com/mgoltzsche/khelm/v2/pkg/helm" "github.com/spf13/cobra" + helmgetter "helm.sh/helm/v3/pkg/getter" ) const ( envKustomizePluginConfig = "KUSTOMIZE_PLUGIN_CONFIG_STRING" envKustomizePluginConfigRoot = "KUSTOMIZE_PLUGIN_CONFIG_ROOT" + envEnableGitGetter = "KHELM_ENABLE_GIT_GETTER" envTrustAnyRepo = "KHELM_TRUST_ANY_REPO" envDebug = "KHELM_DEBUG" envHelmDebug = "HELM_DEBUG" @@ -36,11 +40,15 @@ func Execute(reader io.Reader, writer io.Writer) error { helmDebug, _ := strconv.ParseBool(os.Getenv(envHelmDebug)) h := helm.NewHelm() debug = debug || helmDebug + enableGitGetter := false h.Settings.Debug = debug if trustAnyRepo, ok := os.LookupEnv(envTrustAnyRepo); ok { trust, _ := strconv.ParseBool(trustAnyRepo) h.TrustAnyRepository = &trust } + if gitSupportStr, ok := os.LookupEnv(envEnableGitGetter); ok { + enableGitGetter, _ = strconv.ParseBool(gitSupportStr) + } // Run as kustomize plugin (if kustomize-specific env var provided) if kustomizeGenCfgYAML, isKustomizePlugin := os.LookupEnv(envKustomizePluginConfig); isKustomizePlugin { @@ -50,12 +58,16 @@ func Execute(reader io.Reader, writer io.Writer) error { return err } - logVersionPreRun := func(_ *cobra.Command, _ []string) { + preRun := func(_ *cobra.Command, _ []string) { logVersion() + if enableGitGetter { + addGitGetterSupport(h) + } } rootCmd := &cobra.Command{ - PreRun: logVersionPreRun, + PersistentPreRun: preRun, } + rootCmd.PersistentFlags().BoolVar(&enableGitGetter, "enable-git-getter", enableGitGetter, fmt.Sprintf("enable git+https helm repository URL scheme support (%s)", envEnableGitGetter)) errBuf := bytes.Buffer{} if filepath.Base(os.Args[0]) == "khelmfn" { @@ -65,8 +77,7 @@ func Execute(reader io.Reader, writer io.Writer) error { rootCmd.SetOut(writer) rootCmd.SetErr(&errBuf) rootCmd.PersistentFlags().BoolVar(&debug, "debug", debug, fmt.Sprintf("enable debug log (%s)", envDebug)) - rootCmd.PreRun = func(_ *cobra.Command, _ []string) { - logVersion() + rootCmd.PreRun = func(cmd *cobra.Command, args []string) { fmt.Printf("# Reading kpt function input from stdin (use `%s template` to run without kpt)\n", os.Args[0]) } } @@ -88,7 +99,7 @@ In addition to helm's templating capabilities khelm allows to: templateCmd := templateCommand(h, writer) templateCmd.SetOut(writer) templateCmd.SetErr(&errBuf) - templateCmd.PreRun = logVersionPreRun + templateCmd.PreRun = preRun rootCmd.AddCommand(templateCmd) // Run command @@ -110,3 +121,12 @@ func logVersion() { func versionInfo() string { return fmt.Sprintf("%s (helm %s)", khelmVersion, helmVersion) } + +func addGitGetterSupport(h *helm.Helm) { + h.Getters = append(h.Getters, helmgetter.Provider{ + Schemes: git.Schemes, + New: git.New(&h.Settings, h.Repositories, func(ctx context.Context, chartDir, repoDir string) (string, error) { + return h.Package(ctx, chartDir, repoDir, chartDir) + }), + }) +} diff --git a/cmd/khelm/template_test.go b/cmd/khelm/template_test.go index d7d3b57..0683796 100644 --- a/cmd/khelm/template_test.go +++ b/cmd/khelm/template_test.go @@ -93,6 +93,16 @@ func TestTemplateCommand(t *testing.T) { []string{filepath.Join(exampleDir, "chart-hooks"), "--no-hooks"}, 1, "myvalue", }, + { + "git-dependency", + []string{filepath.Join(exampleDir, "git-dependency"), "--enable-git-getter", "--trust-any-repo"}, + 24, "ca-sync", + }, + { + "local-chart-with-transitive-remote-and-git-dependencies", + []string{filepath.Join(exampleDir, "localrefref-with-git"), "--enable-git-getter", "--trust-any-repo"}, + 33, "admission.certmanager.k8s.io", + }, } { t.Run(c.name, func(t *testing.T) { var out bytes.Buffer @@ -128,6 +138,10 @@ func TestTemplateCommandError(t *testing.T) { "reject cluster scoped resources", []string{"cert-manager", "--repo=https://charts.jetstack.io", "--namespaced-only"}, }, + { + "reject git urls by default", + []string{"git-dependency", "--enable-git-getter=false"}, + }, } { t.Run(c.name, func(t *testing.T) { os.Args = append([]string{"testee", "template"}, c.args...) diff --git a/e2e/cli-tests.bats b/e2e/cli-tests.bats index c1951ee..d3d5a16 100755 --- a/e2e/cli-tests.bats +++ b/e2e/cli-tests.bats @@ -1,6 +1,9 @@ #!/usr/bin/env bats +bats_require_minimum_version 1.5.0 + : ${IMAGE:=mgoltzsche/khelm:latest} + EXAMPLE_DIR="$(pwd)/example" OUT_DIR="$(mktemp -d)" @@ -20,6 +23,24 @@ teardown() { grep -q myrelease "$OUT_DIR/subdir/manifest.yaml" } +@test "CLI should reject repository when not in repositories.yaml and trust-any disabled" { + run -1 docker run --rm -u $(id -u):$(id -g) -v "$OUT_DIR:/out" -e KHELM_TRUST_ANY_REPO=false "$IMAGE" template cert-manager \ + --name=myrelease \ + --version 1.0.4 \ + --repo https://charts.jetstack.io \ + --output /out/subdir/manifest.yaml \ + --debug +} + +@test "CLI should build local chart" { + docker run --rm -u $(id -u):$(id -g) -v "$OUT_DIR:/out" -v "$EXAMPLE_DIR/release-name:/chart" "$IMAGE" template /chart \ + --version 1.2.3 \ + --output /out/manifest.yaml \ + --debug + ls -la "$OUT_DIR" "$OUT_DIR/manifest.yaml" >&2 + cat "$OUT_DIR/manifest.yaml" | tee /dev/stdout /dev/stderr | grep -q 'chartVersion: 1.2.3' +} + @test "CLI should output kustomization" { docker run --rm -u $(id -u):$(id -g) -v "$OUT_DIR:/out" -v "$EXAMPLE_DIR/namespace:/chart" "$IMAGE" template /chart \ --output /out/kdir/ \ @@ -37,4 +58,46 @@ teardown() { --debug [ -f "$OUT_DIR/manifest.yaml" ] grep -q myreleasex "$OUT_DIR/manifest.yaml" -} \ No newline at end of file +} + +@test "CLI should accept git url as helm repository" { + docker run --rm -u $(id -u):$(id -g) -v "$OUT_DIR:/out" \ + -e KHELM_ENABLE_GIT_GETTER=true \ + "$IMAGE" template cert-manager \ + --repo git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2 \ + --output /out/manifest.yaml \ + --debug + [ -f "$OUT_DIR/manifest.yaml" ] + grep -q ca-sync "$OUT_DIR/manifest.yaml" +} + +@test "CLI should cache git repository" { + mkdir $OUT_DIR/cache + docker run --rm -u $(id -u):$(id -g) -v "$OUT_DIR:/out" -v "$OUT_DIR/cache:/helm/cache" \ + -e KHELM_ENABLE_GIT_GETTER=true \ + "$IMAGE" template cert-manager \ + --repo git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2 \ + --output /out/manifest.yaml \ + --debug + [ -f "$OUT_DIR/manifest.yaml" ] + grep -q ca-sync "$OUT_DIR/manifest.yaml" + rm -f "$OUT_DIR/manifest.yaml" + docker run --rm -u $(id -u):$(id -g) -v "$OUT_DIR:/out" -v "$OUT_DIR/cache:/helm/cache" \ + -e KHELM_ENABLE_GIT_GETTER=true \ + --network=none "$IMAGE" template cert-manager \ + --repo git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2 \ + --output /out/manifest.yaml \ + --debug + [ -f "$OUT_DIR/manifest.yaml" ] + grep -q ca-sync "$OUT_DIR/manifest.yaml" +} + +@test "CLI should reject git repository when not in repositories.yaml and trust-any disabled" { + run -1 docker run --rm -u $(id -u):$(id -g) -v "$OUT_DIR:/out" \ + -e KHELM_ENABLE_GIT_GETTER=true \ + -e KHELM_TRUST_ANY_REPO=false \ + "$IMAGE" template cert-manager \ + --repo git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2 \ + --output /out/manifest.yaml \ + --debug +} diff --git a/example/git-dependency/Chart.lock b/example/git-dependency/Chart.lock new file mode 100644 index 0000000..b47d51d --- /dev/null +++ b/example/git-dependency/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: cert-manager + repository: git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2 + version: v0.6.6 +digest: sha256:cd75b404696beff523a4297ba055389e9dd9e8214f47c668cfbf3f200ea41191 +generated: "2022-10-14T00:58:00.186187675+02:00" diff --git a/example/git-dependency/Chart.yaml b/example/git-dependency/Chart.yaml new file mode 100644 index 0000000..1c8081f --- /dev/null +++ b/example/git-dependency/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +description: example chart using a git url as dependency +name: git-dependency +version: 0.1.0 +dependencies: +- name: cert-manager + version: "x.x.x" + repository: "git+https://github.com/cert-manager/cert-manager@deploy/charts?ref=v0.6.2" diff --git a/example/git-dependency/generator.yaml b/example/git-dependency/generator.yaml new file mode 100644 index 0000000..ac0125e --- /dev/null +++ b/example/git-dependency/generator.yaml @@ -0,0 +1,6 @@ +apiVersion: khelm.mgoltzsche.github.com/v2 +kind: ChartRenderer +metadata: + name: cert-manager + namespace: cert-manager +chart: . diff --git a/example/git-dependency/kustomization.yaml b/example/git-dependency/kustomization.yaml new file mode 100644 index 0000000..318ffad --- /dev/null +++ b/example/git-dependency/kustomization.yaml @@ -0,0 +1,2 @@ +generators: +- generator.yaml diff --git a/example/localrefref-with-git/Chart.lock b/example/localrefref-with-git/Chart.lock new file mode 100644 index 0000000..8449ba1 --- /dev/null +++ b/example/localrefref-with-git/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: intermediate-chart + repository: file://../localref/intermediate-chart + version: 0.1.1 +- name: git-https-dependency + repository: git+https://github.com/mgoltzsche/khelm@example?ref=6d1374f17decb68f7876ae1f592d5bd04c74fee5 + version: 0.1.0 +digest: sha256:020a51536f70f9fa7c29b0c1573ea40abbde749c89148a35720efddf7709a003 +generated: "2022-10-30T20:22:52.241871102+01:00" diff --git a/example/localrefref-with-git/Chart.yaml b/example/localrefref-with-git/Chart.yaml new file mode 100644 index 0000000..ab5808f --- /dev/null +++ b/example/localrefref-with-git/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +description: Chart that refers another one that has dependencies itself +name: myumbrella-chart +version: 0.2.0 +dependencies: +- name: intermediate-chart + version: "~0.1.0" + repository: "file://../localref/intermediate-chart" +- name: git-https-dependency + version: "x.x.x" + repository: "git+https://github.com/mgoltzsche/khelm@example?ref=6d1374f17decb68f7876ae1f592d5bd04c74fee5" diff --git a/example/localrefref-with-git/generator.yaml b/example/localrefref-with-git/generator.yaml new file mode 100644 index 0000000..b0e3b6f --- /dev/null +++ b/example/localrefref-with-git/generator.yaml @@ -0,0 +1,6 @@ +apiVersion: khelm.mgoltzsche.github.com/v2 +kind: ChartRenderer +metadata: + name: mychart + namespace: myotherns +chart: . diff --git a/example/release-name/generator.yaml b/example/release-name/generator.yaml index 68ac00d..141ae0a 100644 --- a/example/release-name/generator.yaml +++ b/example/release-name/generator.yaml @@ -4,3 +4,4 @@ metadata: name: my-release-name chart: . kubeVersion: 1.17 +version: 1.9.3 diff --git a/example/release-name/templates/example.yaml b/example/release-name/templates/example.yaml index f117e32..d02176a 100644 --- a/example/release-name/templates/example.yaml +++ b/example/release-name/templates/example.yaml @@ -5,3 +5,4 @@ metadata: data: key: b k8sVersion: {{ .Capabilities.KubeVersion }} + chartVersion: {{ .Chart.Version }} diff --git a/go.mod b/go.mod index 53d5d47..0ed704e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.18 require ( github.com/Masterminds/semver/v3 v3.1.1 + github.com/go-git/go-git/v5 v5.4.2 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.5.0 github.com/stretchr/testify v1.8.0 @@ -27,8 +28,11 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/sprig/v3 v3.2.2 // indirect github.com/Masterminds/squirrel v1.5.3 // indirect + github.com/Microsoft/go-winio v0.5.1 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/acomagu/bufpipe v1.0.3 // indirect github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect @@ -44,10 +48,13 @@ require ( github.com/docker/go-metrics v0.0.1 // indirect github.com/docker/go-units v0.4.0 // indirect github.com/emicklei/go-restful/v3 v3.8.0 // indirect + github.com/emirpasic/gods v1.12.0 // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/fatih/color v1.13.0 // indirect github.com/go-errors/errors v1.0.1 // indirect + github.com/go-git/gcfg v1.5.0 // indirect + github.com/go-git/go-billy/v5 v5.3.1 // indirect github.com/go-gorp/gorp/v3 v3.0.2 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect @@ -68,9 +75,11 @@ require ( github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jmoiron/sqlx v1.3.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -82,6 +91,7 @@ require ( github.com/mattn/go-runewidth v0.0.9 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/locker v1.0.1 // indirect @@ -102,10 +112,12 @@ require ( github.com/prometheus/procfs v0.7.3 // indirect github.com/rubenv/sql-migrate v1.1.2 // indirect github.com/russross/blackfriday v1.5.2 // indirect + github.com/sergi/go-diff v1.1.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/sirupsen/logrus v1.8.1 // indirect github.com/spf13/cast v1.4.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/xanzy/ssh-agent v0.3.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect @@ -125,6 +137,7 @@ require ( google.golang.org/grpc v1.47.0 // indirect google.golang.org/protobuf v1.28.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/api v0.25.2 // indirect k8s.io/apiextensions-apiserver v0.25.2 // indirect diff --git a/go.sum b/go.sum index 5a67b99..189b191 100644 --- a/go.sum +++ b/go.sum @@ -57,24 +57,34 @@ github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmy github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Masterminds/squirrel v1.5.3 h1:YPpoceAcxuzIljlr5iWpNKaql7hLeG1KLSrhvdHpkZc= github.com/Masterminds/squirrel v1.5.3/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= github.com/Microsoft/go-winio v0.5.1 h1:aPJp2QD7OOrhO5tQXqQoGSJc+DjDtWTGLOmNyAm6FgY= +github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/hcsshim v0.9.3 h1:k371PzBuRrz2b+ebGuI2nVgVhgsVX60jMfSw80NECxo= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= +github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= +github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= 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= @@ -144,6 +154,8 @@ github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153 h1:yUdfgN0XgIJw7fo github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful/v3 v3.8.0 h1:eCZ8ulSerjdAiaNpF7GxXIE7ZCMo1moN1qX+S609eVw= github.com/emicklei/go-restful/v3 v3.8.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -161,10 +173,22 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv 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 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= +github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= +github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= +github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -329,6 +353,9 @@ github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 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= @@ -347,6 +374,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 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/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= @@ -386,6 +415,8 @@ 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/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= @@ -410,6 +441,8 @@ github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFW 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-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= @@ -513,8 +546,10 @@ github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXY github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= @@ -549,6 +584,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= @@ -590,6 +627,7 @@ go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9i go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/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-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -599,6 +637,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -674,6 +714,7 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -722,6 +763,7 @@ golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191002063906-3421d5a6bb1c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -756,9 +798,11 @@ golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -967,6 +1011,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/getter/git/checkout.go b/pkg/getter/git/checkout.go new file mode 100644 index 0000000..d971234 --- /dev/null +++ b/pkg/getter/git/checkout.go @@ -0,0 +1,84 @@ +package git + +import ( + "context" + "encoding/hex" + "fmt" + "log" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "helm.sh/helm/v3/pkg/repo" +) + +func gitCheckoutImpl(ctx context.Context, repoURL, ref string, repo *repo.Entry, destDir string) error { + cloneOpts := git.CloneOptions{ + URL: repoURL, + RemoteName: "origin", + NoCheckout: true, + } + isCommitRef := isCommitSHA(ref) + if !isCommitRef { // cannot find commit in other branch when checking out single branch + cloneOpts.SingleBranch = true + cloneOpts.Depth = 1 + } + scheme := strings.SplitN(repoURL, ":", 2)[0] + switch scheme { + case "https": + cloneOpts.Auth = &http.BasicAuth{ + Username: repo.Username, + Password: repo.Password, + } + default: + if repo.Username != "" || repo.Password != "" { + log.Printf("WARNING: ignoring auth config for %s since authentication is not supported for url scheme %q", repoURL, scheme) + } + } + r, err := git.PlainCloneContext(ctx, destDir, false, &cloneOpts) + if err != nil { + return fmt.Errorf("git clone: %w", err) + } + tree, err := r.Worktree() + if err != nil { + return fmt.Errorf("git worktree: %w", err) + } + // TODO: support sparse checkout, see https://github.com/go-git/go-git/issues/90 + refType := "without ref" + opts := git.CheckoutOptions{} + if ref != "" { + if isCommitRef { + opts.Hash = plumbing.NewHash(ref) + refType = fmt.Sprintf("commit %s", ref) + } else { + opts.Branch = plumbing.ReferenceName(fmt.Sprintf("refs/tags/%s", ref)) + refType = fmt.Sprintf("tag %s", ref) + } + } + err = tree.Checkout(&opts) + if err != nil { + return fmt.Errorf("git checkout %s: %w", refType, err) + } + /*err := runCmds(destDir, [][]string{ + {"git", "init", "--quiet"}, + {"git", "remote", "add", "origin", repoURL}, + + {"git", "config", "core.sparseCheckout", "true"}, + {"git", "sparse-checkout", "set", path}, + {"git", "pull", "--quiet", "--depth", "1", "origin", ref}, + + //{"git", "fetch", "--quiet", "--tags", "origin"}, + //{"git", "checkout", "--quiet", ref}, + })*/ + return nil +} + +func isCommitSHA(s string) bool { + if len(s) == 40 { + if _, err := hex.DecodeString(s); err == nil { + return true + } + } + return false +} diff --git a/pkg/getter/git/gitgetter.go b/pkg/getter/git/gitgetter.go new file mode 100644 index 0000000..58d648e --- /dev/null +++ b/pkg/getter/git/gitgetter.go @@ -0,0 +1,244 @@ +package git + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "io" + "log" + "os" + "path" + "path/filepath" + "strings" + + "github.com/mgoltzsche/khelm/v2/pkg/repositories" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/cli" + helmgetter "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" + helmyaml "sigs.k8s.io/yaml" +) + +var ( + Schemes = []string{"git+https", "git+ssh"} + gitCheckout = gitCheckoutImpl +) + +type HelmPackageFunc func(ctx context.Context, path, repoDir string) (string, error) + +type RepositoriesFunc func() (repositories.Interface, error) + +func New(settings *cli.EnvSettings, reposFn RepositoriesFunc, packageFn HelmPackageFunc) helmgetter.Constructor { + return func(o ...helmgetter.Option) (helmgetter.Getter, error) { + repos, err := reposFn() + if err != nil { + return nil, err + } + return &gitIndexGetter{ + settings: settings, + repos: repos, + packageFn: packageFn, + }, nil + } +} + +type gitIndexGetter struct { + repos repositories.Interface + settings *cli.EnvSettings + Getters helmgetter.Providers + packageFn HelmPackageFunc +} + +func (g *gitIndexGetter) Get(location string, options ...helmgetter.Option) (*bytes.Buffer, error) { + ctx := context.Background() + ref, err := parseURL(location) + if err != nil { + return nil, err + } + if ref.Ref == "" { + log.Println("WARNING: Specifying a git URL without the ref parameter may return a cached, outdated version") + } + isRepoIndex := path.Base(ref.Path) == "index.yaml" + var b []byte + if isRepoIndex { + // Generate repo index from directory + ref = ref.Dir() + repoDir, err := download(ctx, ref, g.settings.RepositoryCache, g.repos) + if err != nil { + return nil, err + } + dir := filepath.Join(repoDir, filepath.FromSlash(ref.Path)) + idx, err := generateRepoIndex(dir, g.settings.RepositoryCache, ref) + if err != nil { + return nil, fmt.Errorf("generate git repo index: %w", err) + } + b, err = helmyaml.Marshal(idx) + if err != nil { + return nil, fmt.Errorf("marshal generated repo index: %w", err) + } + } else { + // Build and package chart + ref.Path = strings.TrimSuffix(ref.Path, ".tgz") + chartPath := filepath.FromSlash(ref.Path) + repoDir, err := download(ctx, ref, g.settings.RepositoryCache, g.repos) + if err != nil { + return nil, err + } + if _, e := os.Stat(filepath.Join(filepath.Join(repoDir, chartPath), "Chart.yaml")); e == nil { + tmpDir, err := os.MkdirTemp("", "khelm-git-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + tmpRepoDir := filepath.Join(tmpDir, "repo") + tmpChartDir := filepath.Join(tmpRepoDir, chartPath) + err = copyDir(repoDir, tmpRepoDir) + if err != nil { + return nil, fmt.Errorf("make temp git repo copy: %w", err) + } + tgzFile, err := g.packageFn(ctx, tmpChartDir, tmpRepoDir) + if err != nil { + return nil, fmt.Errorf("package %s: %w", location, err) + } + b, err = os.ReadFile(tgzFile) + if err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("unsupported git location: %s", location) + } + } + var buf bytes.Buffer + _, err = buf.Write(b) + return &buf, err +} + +func generateRepoIndex(dir, cacheDir string, u *gitURL) (*repo.IndexFile, error) { + idx := repo.NewIndexFile() + idx.APIVersion = "v2" + idx.Entries = map[string]repo.ChartVersions{} + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, file := range files { + if file.IsDir() { + chartDir := filepath.Join(dir, file.Name()) + chartYamlFile := filepath.Join(chartDir, "Chart.yaml") + b, err := os.ReadFile(chartYamlFile) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + chrt := chart.Metadata{} + err = helmyaml.Unmarshal(b, &chrt) + if err != nil { + return nil, fmt.Errorf("read %s: %w", chartYamlFile, err) + } + idx.Entries[file.Name()] = repo.ChartVersions{ + { + Metadata: &chrt, + URLs: []string{"git+" + u.JoinPath(file.Name()+".tgz").String()}, + }, + } + } + } + return idx, nil +} + +func download(ctx context.Context, ref *gitURL, cacheDir string, repos repositories.Interface) (string, error) { + repoRef := *ref + repoRef.Path = "" + cacheKey := fmt.Sprintf("sha256-%x", sha256.Sum256([]byte(repoRef.String()))) + cacheDir = filepath.Join(cacheDir, "git") + destDir := filepath.Join(cacheDir, cacheKey) + + if _, e := os.Stat(destDir); os.IsNotExist(e) { + auth, _, err := repos.Get("git+" + ref.String()) + if err != nil { + return "", err + } + err = os.MkdirAll(cacheDir, 0755) + if err != nil { + return "", err + } + tmpDir, err := os.MkdirTemp(cacheDir, ".tmp-") + if err != nil { + return "", err + } + defer os.RemoveAll(tmpDir) + + tmpRepoDir := tmpDir + err = gitCheckout(ctx, ref.Repo, ref.Ref, auth, tmpRepoDir) + if err != nil { + return "", err + } + if err = os.Rename(tmpRepoDir, destDir); err != nil && !os.IsExist(err) { + return "", err + } + } + return destDir, nil +} + +func copyDir(srcDir, dstDir string) error { + files, err := os.ReadDir(srcDir) + if err != nil { + return err + } + err = os.Mkdir(dstDir, 0750) + if err != nil { + return err + } + for _, file := range files { + name := file.Name() + if name == ".git" { + continue + } + srcFile := filepath.Join(srcDir, name) + dstFile := filepath.Join(dstDir, name) + if file.IsDir() { + err = copyDir(srcFile, dstFile) + if err != nil { + return err + } + } else if file.Type() == os.ModeSymlink { + linkDest, err := os.Readlink(srcFile) + if err != nil { + return err + } + err = os.Symlink(srcFile, linkDest) + if err != nil { + return err + } + } else if file.Type() != os.ModeCharDevice && file.Type() != os.ModeDevice && file.Type() != os.ModeIrregular { + err = copyFile(srcFile, dstFile) + if err != nil { + return fmt.Errorf("copy %s to %s", srcFile, dstFile) + } + } else { + log.Printf("WARNING: ignoring file with unsupported type (%d) found at %s", file.Type(), srcFile) + } + } + return nil +} + +func copyFile(src, dst string) error { + fin, err := os.Open(src) + if err != nil { + return err + } + defer fin.Close() + fout, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer fout.Close() + _, err = io.Copy(fout, fin) + if err != nil { + return err + } + return nil +} diff --git a/pkg/getter/git/gitgetter_test.go b/pkg/getter/git/gitgetter_test.go new file mode 100644 index 0000000..b7cba5f --- /dev/null +++ b/pkg/getter/git/gitgetter_test.go @@ -0,0 +1,78 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mgoltzsche/khelm/v2/pkg/repositories" + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" + "sigs.k8s.io/yaml" +) + +func TestGitGetter(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "khelm-git-getter-test-") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + gitCheckout = func(_ context.Context, repoURL, ref string, auth *repo.Entry, destDir string) error { + err := os.MkdirAll(filepath.Join(destDir, "mypath", "fakechartdir"), 0755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(destDir, "mypath", "fakechartdir", "Chart.yaml"), []byte(` +apiVersion: v2 +name: fake-chart +version: 0.1.0`), 0600) + require.NoError(t, err) + return nil + } + packageCalls := 0 + settings := cli.New() + settings.RepositoryCache = filepath.Join(tmpDir, "cache") + fakePackageFn := func(ctx context.Context, path, repoDir string) (string, error) { + packageCalls++ + file := filepath.Join(tmpDir, "fake-chart.tgz") + err := os.WriteFile(file, []byte("fake-chart-tgz-contents "+path), 0600) + require.NoError(t, err) + return file, nil + } + reposFn := func() (repositories.Interface, error) { + trust := true + return repositories.New(*settings, getter.All(settings), &trust) + } + testee := New(settings, reposFn, fakePackageFn) + getter, err := testee() + require.NoError(t, err) + + b, err := getter.Get("git+https://git.example.org/org/repo@mypath/index.yaml?ref=v0.6.2") + require.NoError(t, err) + idx := repo.NewIndexFile() + err = yaml.Unmarshal(b.Bytes(), idx) + require.NoErrorf(t, err, "unmarshal Get() result: %s", b.String()) + expect := repo.NewIndexFile() + expect.APIVersion = "v2" + fakeChartURL := "git+https://git.example.org/org/repo@mypath/fakechartdir.tgz?ref=v0.6.2" + expect.Entries = map[string]repo.ChartVersions{ + "fakechartdir": []*repo.ChartVersion{ + { + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "fake-chart", + Version: "0.1.0", + }, + URLs: []string{fakeChartURL}, + }, + }, + } + expect.Generated = idx.Generated + require.Equal(t, expect, idx, "should generate repository index from directory") + + b, err = getter.Get(fakeChartURL) + require.NoError(t, err) + require.Contains(t, b.String(), "fake-chart-tgz-contents /tmp/khelm-git-", "should return packaged chart") + require.Truef(t, strings.HasSuffix(b.String(), "/repo/mypath/fakechartdir"), "should end with path within repo but was: %s", b.String()) +} diff --git a/pkg/getter/git/url.go b/pkg/getter/git/url.go new file mode 100644 index 0000000..58ef975 --- /dev/null +++ b/pkg/getter/git/url.go @@ -0,0 +1,66 @@ +package git + +import ( + "fmt" + "net/url" + "path" + "strings" +) + +type gitURL struct { + Repo string + Ref string + Path string +} + +func (u *gitURL) Dir() *gitURL { + n := *u + n.Path = path.Dir(n.Path) + return &n +} + +func (u *gitURL) JoinPath(p ...string) *gitURL { + n := *u + n.Path = path.Join(append([]string{n.Path}, p...)...) + return &n +} + +func (u *gitURL) String() string { + ref := "" + if u.Ref != "" { + ref = fmt.Sprintf("?ref=%s", u.Ref) + } + path := u.Path + if path != "" { + path = fmt.Sprintf("@%s", path) + } + return fmt.Sprintf("%s%s%s", u.Repo, path, ref) +} + +func parseURL(s string) (*gitURL, error) { + u, err := url.Parse(s) + if err != nil { + return nil, err + } + if !strings.HasPrefix(u.Scheme, "git+") { + return nil, fmt.Errorf("unsupported git url scheme %q", u.Scheme) + } + s = s[4:] + queryStartPos := strings.LastIndex(s, "?") + if queryStartPos < 0 { + queryStartPos = len(s) + } + repoEndPosition := queryStartPos + pathStartPos := strings.LastIndex(s, "@") + if pathStartPos < 0 { + pathStartPos = queryStartPos + } else { + repoEndPosition = pathStartPos + pathStartPos++ + } + return &gitURL{ + Repo: s[:repoEndPosition], + Ref: u.Query().Get("ref"), + Path: s[pathStartPos:queryStartPos], + }, nil +} diff --git a/pkg/getter/git/url_test.go b/pkg/getter/git/url_test.go new file mode 100644 index 0000000..c4c857e --- /dev/null +++ b/pkg/getter/git/url_test.go @@ -0,0 +1,83 @@ +package git + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitURL(t *testing.T) { + for _, tc := range []struct { + name string + input string + expect gitURL + }{ + { + name: "repo", + input: "git+https://example.org/git/org/user", + expect: gitURL{ + Repo: "https://example.org/git/org/user", + }, + }, + { + name: "repo path", + input: "git+https://example.org/git/org/user@some/path", + expect: gitURL{ + Repo: "https://example.org/git/org/user", + Path: "some/path", + }, + }, + { + name: "repo ref", + input: "git+https://example.org/git/org/user?ref=v1.2.3", + expect: gitURL{ + Repo: "https://example.org/git/org/user", + Ref: "v1.2.3", + }, + }, + { + name: "repo path ref", + input: "git+https://example.org/git/org/user@some/path?ref=v1.2.3", + expect: gitURL{ + Repo: "https://example.org/git/org/user", + Ref: "v1.2.3", + Path: "some/path", + }, + }, + { + name: "abs path", + input: "git+https://example.org/git/org/user@/some/path?ref=v1.2.3", + expect: gitURL{ + Repo: "https://example.org/git/org/user", + Ref: "v1.2.3", + Path: "/some/path", + }, + }, + { + name: "ssh", + input: "git+ssh://git@example.org/git/org/user@some/path?ref=v1.2.3", + expect: gitURL{ + Repo: "ssh://git@example.org/git/org/user", + Ref: "v1.2.3", + Path: "some/path", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actual, err := parseURL(tc.input) + require.NoError(t, err) + require.Equal(t, &tc.expect, actual, "parseURL()") + require.Equal(t, tc.input[4:], actual.String(), "url.String()") + }) + } +} +func TestGitURLJoinPath(t *testing.T) { + u := gitURL{ + Repo: "ssh://example.org/repo", + Ref: "v1.2.3", + Path: "some/path", + } + n := u.JoinPath("other", "path") + require.Equal(t, "some/path/other/path", n.Path) + require.Equal(t, "some/path", u.Path) +} diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go index c92278e..48946bb 100644 --- a/pkg/helm/helm.go +++ b/pkg/helm/helm.go @@ -4,16 +4,22 @@ import ( "os" "path/filepath" + "github.com/mgoltzsche/khelm/v2/pkg/repositories" "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/helmpath" ) +func IsUntrustedRepository(err error) bool { + return repositories.IsUntrustedRepository(err) +} + // Helm maintains the helm environment state type Helm struct { TrustAnyRepository *bool Settings cli.EnvSettings Getters getter.Providers + repos repositories.Interface } // NewHelm creates a new helm environment @@ -25,3 +31,15 @@ func NewHelm() *Helm { } return &Helm{Settings: *settings, Getters: getter.All(settings)} } + +func (h *Helm) Repositories() (repositories.Interface, error) { + if h.repos != nil { + return h.repos, nil + } + repos, err := repositories.New(h.Settings, h.Getters, h.TrustAnyRepository) + if err != nil { + return nil, err + } + h.repos = repos + return repos, nil +} diff --git a/pkg/helm/load.go b/pkg/helm/load.go index e9d7f6a..27e160b 100644 --- a/pkg/helm/load.go +++ b/pkg/helm/load.go @@ -10,6 +10,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/mgoltzsche/khelm/v2/pkg/config" + "github.com/mgoltzsche/khelm/v2/pkg/repositories" "github.com/pkg/errors" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" @@ -28,7 +29,11 @@ func (h *Helm) loadChart(ctx context.Context, cfg *config.ChartConfig) (*chart.C fileExists := err == nil if cfg.Repository == "" { if fileExists { - return h.buildAndLoadLocalChart(ctx, cfg) + repos, err := h.Repositories() + if err != nil { + return nil, err + } + return buildAndLoadLocalChart(ctx, cfg, repos, h.Settings, h.Getters) } else if l := strings.Split(cfg.Chart, "/"); len(l) == 2 && l[0] != "" && l[1] != "" && l[0] != ".." && l[0] != "." { cfg.Repository = "@" + l[0] cfg.Chart = l[1] @@ -41,7 +46,11 @@ func (h *Helm) loadChart(ctx context.Context, cfg *config.ChartConfig) (*chart.C func (h *Helm) loadRemoteChart(ctx context.Context, cfg *config.ChartConfig) (*chart.Chart, error) { repoURLs := map[string]struct{}{cfg.Repository: {}} - repos, err := reposForURLs(repoURLs, h.TrustAnyRepository, &h.Settings, h.Getters) + repos, err := h.Repositories() + if err != nil { + return nil, err + } + reqRepos, err := reposForURLs(repoURLs, repos) if err != nil { return nil, err } @@ -50,18 +59,19 @@ func (h *Helm) loadRemoteChart(ctx context.Context, cfg *config.ChartConfig) (*c return nil, err } if isRange { - if err = repos.UpdateIndex(ctx); err != nil { + // TODO: avoid updating the same repo index multiple times when using multiple charts from the same repo + if err = reqRepos.UpdateIndex(ctx); err != nil { return nil, err } } - chartPath, err := locateChart(ctx, &cfg.LoaderConfig, repos, &h.Settings, h.Getters) + chartPath, err := locateChart(ctx, &cfg.LoaderConfig, reqRepos, &h.Settings, h.Getters) if err != nil { return nil, err } return loader.Load(chartPath) } -func (h *Helm) buildAndLoadLocalChart(ctx context.Context, cfg *config.ChartConfig) (*chart.Chart, error) { +func buildAndLoadLocalChart(ctx context.Context, cfg *config.ChartConfig, repos repositories.Interface, settings cli.EnvSettings, getters getter.Providers) (*chart.Chart, error) { chartPath := absPath(cfg.Chart, cfg.BaseDir) chartRequested, err := loader.Load(chartPath) if err != nil { @@ -76,31 +86,32 @@ func (h *Helm) buildAndLoadLocalChart(ctx context.Context, cfg *config.ChartConf } // Create (temporary) repository configuration that includes all dependencies - repos, err := reposForDependencies(dependencies, h.TrustAnyRepository, &h.Settings, h.Getters) + depRepos, err := reposForDependencies(dependencies, repos) if err != nil { return nil, errors.Wrap(err, "init temp repositories.yaml") } - repos.RequireTempHelmHome(len(localCharts) > 1) - repos, err = repos.Apply() - if err != nil { - return nil, err + if len(localCharts) > 1 { + depRepos, err = depRepos.TempRepositories() + if err != nil { + return nil, err + } } - defer repos.Close() - settings := h.Settings - settings.RepositoryConfig = repos.FilePath() + defer depRepos.Close() + tmpSettings := settings + tmpSettings.RepositoryConfig = depRepos.File() // Download/update repo indices if needsRepoIndexUpdate { - err = repos.UpdateIndex(ctx) + err = depRepos.UpdateIndex(ctx) } else { - err = repos.DownloadIndexFilesIfNotExist(ctx) + err = depRepos.FetchMissingIndexFiles(ctx) } if err != nil { return nil, err } // Build local charts recursively - needsReload, err := buildLocalCharts(ctx, localCharts, &cfg.LoaderConfig, repos, &settings, h.Getters) + needsReload, err := buildLocalCharts(ctx, localCharts, &cfg.LoaderConfig, depRepos, &tmpSettings, getters) if err != nil { return nil, errors.Wrap(err, "build/fetch dependencies") } @@ -111,6 +122,9 @@ func (h *Helm) buildAndLoadLocalChart(ctx context.Context, cfg *config.ChartConf return nil, errors.Wrapf(err, "failed reloading chart %s after dependency download", cfg.Chart) } } + if cfg.Version != "" { + chartRequested.Metadata.Version = cfg.Version + } return chartRequested, nil } @@ -162,7 +176,7 @@ func collectCharts(chartRequested *chart.Chart, chartPath string, cfg *config.Ch if needsUpdate { needsRepoIndexUpdate = true } - } else if strings.HasPrefix(dep.Repository, "https://") || strings.HasPrefix(dep.Repository, "http://") { + } else { *deps = append(*deps, dep) if chartRequested.Lock == nil { // Update repo index when remote dependencies present but no lock file diff --git a/pkg/helm/locate.go b/pkg/helm/locate.go index 516b401..0033f18 100644 --- a/pkg/helm/locate.go +++ b/pkg/helm/locate.go @@ -27,7 +27,7 @@ func locateChart(ctx context.Context, cfg *config.LoaderConfig, repos repository return name, errors.Errorf("path %q not found", name) } - repoEntry, err := repos.Get(cfg.Repository) + repoEntry, _, err := repos.Get(cfg.Repository) if err != nil { return "", err } @@ -42,8 +42,7 @@ func locateChart(ctx context.Context, cfg *config.LoaderConfig, repos repository return "", errors.Wrap(err, "failed to make chart URL absolute") } - chartCacheDir := filepath.Join(settings.RepositoryCache, "khelm") - cacheFile, err := cacheFilePath(chartURL, cv, chartCacheDir) + cacheFile, err := cacheFilePath(chartURL, cv, settings.RepositoryCache) if err != nil { return "", errors.Wrap(err, "derive chart cache file") } @@ -127,24 +126,27 @@ func locateChart(ctx context.Context, cfg *config.LoaderConfig, repos repository } } -func cacheFilePath(chartURL string, cv *repo.ChartVersion, cacheDir string) (string, error) { +func cacheFilePath(chartURL string, cv *repo.ChartVersion, cacheRootDir string) (string, error) { + cacheDir := filepath.Join(cacheRootDir, "khelm") u, err := url.Parse(chartURL) if err != nil { - return "", errors.Wrapf(err, "parse chart URL %q", chartURL) + return "", errors.Wrapf(err, "parse chart url %q", chartURL) } - if u.Path == "" { - return "", errors.Errorf("parse chart URL %s: empty path in URL", chartURL) + p := u.Path + if p == "" { + return "", errors.Errorf("invalid chart url %s: empty path", chartURL) } - // Try reading file from cache - path := filepath.Clean(filepath.FromSlash(u.Path)) + path := filepath.Clean(filepath.FromSlash(p)) if strings.Contains(path, "..") { return "", errors.Errorf("get %s: path %q points outside the cache dir", chartURL, path) } digest := "none" if len(cv.Digest) < 16 { // not all the helm repository implementations populate the digest field (e.g. Nexus 3) - log.Printf("WARNING: repo index entry for chart %q does not specify a digest", cv.Name) + if !strings.HasPrefix(chartURL, "git+") { + log.Printf("WARNING: repo index entry for chart %q does not specify a digest", cv.Name) + } } else { digest = cv.Digest[:16] } diff --git a/pkg/helm/package.go b/pkg/helm/package.go new file mode 100644 index 0000000..fe2b784 --- /dev/null +++ b/pkg/helm/package.go @@ -0,0 +1,50 @@ +package helm + +import ( + "context" + + "github.com/mgoltzsche/khelm/v2/pkg/config" + "github.com/mgoltzsche/khelm/v2/pkg/repositories" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" +) + +type PackageOptions struct { + ChartDir string + BaseDir string + DestDir string +} + +// Package builds and packages a local Helm chart. +// Returns the tgz file path. +func (h *Helm) Package(ctx context.Context, chartDir, baseDir, destDir string) (string, error) { + repos, err := h.Repositories() + if err != nil { + return "", err + } + cfg := config.ChartConfig{ + LoaderConfig: config.LoaderConfig{ + Chart: chartDir, + }, + BaseDir: baseDir, + } + return packageHelmChart(ctx, &cfg, repos, h.Settings, h.Getters) +} + +func packageHelmChart(ctx context.Context, cfg *config.ChartConfig, repos repositories.Interface, settings cli.EnvSettings, getters getter.Providers) (string, error) { + // TODO: add unit test (there is an e2e/cli test for this though) + _, err := buildAndLoadLocalChart(ctx, cfg, repos, settings, getters) + if err != nil { + return "", err + } + // See https://github.com/helm/helm/blob/v3.10.0/cmd/helm/package.go#L104 + client := action.NewPackage() + client.Destination = cfg.BaseDir + chartPath := absPath(cfg.Chart, cfg.BaseDir) + tgzFile, err := client.Run(chartPath, map[string]interface{}{}) + if err != nil { + return "", err + } + return tgzFile, nil +} diff --git a/pkg/helm/render_test.go b/pkg/helm/render_test.go index f363261..22f6a42 100644 --- a/pkg/helm/render_test.go +++ b/pkg/helm/render_test.go @@ -15,10 +15,12 @@ import ( "time" "github.com/mgoltzsche/khelm/v2/pkg/config" + "github.com/mgoltzsche/khelm/v2/pkg/repositories" "github.com/stretchr/testify/require" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/repo" "sigs.k8s.io/kustomize/kyaml/yaml" helmyaml "sigs.k8s.io/yaml" @@ -51,6 +53,7 @@ func TestRender(t *testing.T) { {"force-namespace", "example/force-namespace/generator.yaml", []string{"forced-namespace"}, " key: b", nil}, {"kubeVersion", "example/release-name/generator.yaml", []string{}, " k8sVersion: v1.17.0", nil}, {"release-name", "example/release-name/generator.yaml", []string{}, " name: my-release-name-config", nil}, + {"chart-version", "example/release-name/generator.yaml", []string{}, " chartVersion: 1.9.3", nil}, {"exclude", "example/exclude/generator.yaml", []string{"cluster-role-binding-ns"}, " key: b", nil}, {"include", "example/include/generator.yaml", []string{}, " key: b", nil}, {"local-chart-with-local-dependency-and-transitive-remote", "example/localrefref/generator.yaml", []string{}, "rook-ceph-v0.9.3", nil}, @@ -75,10 +78,10 @@ func TestRender(t *testing.T) { var rendered bytes.Buffer absFile := filepath.Join(rootDir, c.file) err := renderFile(t, absFile, true, rootDir, &rendered) - require.NoError(t, err, "render %s%s", cached, absFile) + require.NoErrorf(t, err, "render %s%s", cached, absFile) b := rendered.Bytes() l, err := readYaml(b) - require.NoError(t, err, "rendered %syaml:\n%s", cached, b) + require.NoErrorf(t, err, "rendered %syaml:\n%s", cached, b) require.True(t, len(l) > 0, "%s: rendered result of %s is empty", cached, c.file) require.Contains(t, rendered.String(), c.expectedContained, "%syaml", cached) foundResourceNames := []string{} @@ -188,16 +191,24 @@ func TestRenderRebuildsLocalDependencies(t *testing.T) { } func TestRenderUpdateRepositoryIndexIfChartNotFound(t *testing.T) { + dir, err := os.MkdirTemp("", "khelm-test-") + require.NoError(t, err) + defer os.RemoveAll(dir) settings := cli.New() - repoURL := "https://charts.rook.io/stable" + settings.RepositoryCache = dir trust := true - repos, err := reposForURLs(map[string]struct{}{repoURL: {}}, &trust, settings, getter.All(settings)) - require.NoError(t, err, "use repo") - entry, err := repos.Get(repoURL) + repos, err := repositories.New(*settings, getter.All(settings), &trust) + require.NoError(t, err) + repoURL := "https://charts.rook.io/stable" + reqRepos, err := reposForURLs(map[string]struct{}{repoURL: {}}, repos) + require.NoError(t, err, "reposForURLs") + require.NotNil(t, reqRepos) + entry, trusted, err := reqRepos.Get(repoURL) require.NoError(t, err, "repos.EntryByURL()") - err = repos.Close() + require.False(t, trusted, "trusted") + err = reqRepos.Close() require.NoError(t, err, "repos.Close()") - idxFile := indexFile(entry, settings.RepositoryCache) + idxFile := repoIndexFile(entry, settings.RepositoryCache) idx := repo.NewIndexFile() // write empty index file to cause not found error err = idx.WriteFile(idxFile, 0644) require.NoError(t, err, "write empty index file") @@ -208,16 +219,23 @@ func TestRenderUpdateRepositoryIndexIfChartNotFound(t *testing.T) { } func TestRenderUpdateRepositoryIndexIfDependencyNotFound(t *testing.T) { + dir, err := os.MkdirTemp("", "khelm-test-") + require.NoError(t, err) + defer os.RemoveAll(dir) settings := cli.New() + settings.RepositoryCache = dir repoURL := "https://kubernetes-charts.storage.googleapis.com" trust := true - repos, err := reposForURLs(map[string]struct{}{repoURL: {}}, &trust, settings, getter.All(settings)) + repos, err := repositories.New(*settings, getter.All(settings), &trust) + require.NoError(t, err) + reqRepos, err := reposForURLs(map[string]struct{}{repoURL: {}}, repos) require.NoError(t, err, "use repo") - entry, err := repos.Get(repoURL) + entry, trusted, err := reqRepos.Get(repoURL) require.NoError(t, err, "repos.Get()") - err = repos.Close() + require.False(t, trusted, "trusted") + err = reqRepos.Close() require.NoError(t, err, "repos.Close()") - idxFile := indexFile(entry, settings.RepositoryCache) + idxFile := repoIndexFile(entry, settings.RepositoryCache) idx := repo.NewIndexFile() // write empty index file to cause not found error err = idx.WriteFile(idxFile, 0644) require.NoError(t, err, "write empty index file") @@ -328,6 +346,10 @@ func TestRenderRepositoryCredentials(t *testing.T) { } } +func repoIndexFile(entry *repo.Entry, cacheDir string) string { + return filepath.Join(cacheDir, helmpath.CacheIndexFile(entry.Name)) +} + type fakePrivateChartServerHandler struct { repo *repo.Entry config *config.LoaderConfig diff --git a/pkg/helm/repositories.go b/pkg/helm/repositories.go index 0688cc9..04d9977 100644 --- a/pkg/helm/repositories.go +++ b/pkg/helm/repositories.go @@ -1,433 +1,141 @@ package helm import ( - "bytes" "context" - "crypto" - "encoding/hex" "fmt" - "io" "log" "os" - "path/filepath" "sort" - "strings" + "github.com/mgoltzsche/khelm/v2/pkg/repositories" "github.com/pkg/errors" "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/cli" - "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/repo" ) -type untrustedRepoError struct { - error -} - -func (e *untrustedRepoError) Format(s fmt.State, verb rune) { - f, isFormatter := e.error.(interface { - Format(s fmt.State, verb rune) - }) - if isFormatter { - f.Format(s, verb) - return - } - fmt.Fprintf(s, "%s", e.error) -} - -// IsUntrustedRepository return true if the provided error is an untrusted repository error -func IsUntrustedRepository(err error) bool { - _, ok := errors.Cause(err).(*untrustedRepoError) - return ok -} - type repositoryConfig interface { Close() error - FilePath() string + File() string ResolveChartVersion(ctx context.Context, name, version, repo string) (*repo.ChartVersion, error) - Get(repo string) (*repo.Entry, error) + Get(repo string) (*repo.Entry, bool, error) UpdateIndex(context.Context) error - DownloadIndexFilesIfNotExist(context.Context) error - RequireTempHelmHome(bool) - Apply() (repositoryConfig, error) + FetchMissingIndexFiles(context.Context) error + TempRepositories() (repositoryConfig, error) } -func reposForURLs(repoURLs map[string]struct{}, trustAnyRepo *bool, settings *cli.EnvSettings, getters getter.Providers) (repositoryConfig, error) { - repos, err := newRepositories(settings, getters) +func reposForURLs(repoURLs map[string]struct{}, repos repositories.Interface) (repositoryConfig, error) { + repoFile, untrusted, err := repoFileFromURLs(repoURLs, repos) if err != nil { return nil, err } - err = repos.setRepositoriesFromURLs(repoURLs, trustAnyRepo) - if err != nil { - return nil, err + r := &requiredRepos{ + Interface: repos, + repoFile: repoFile, } - return repos, nil + if untrusted { + return newTempRepositories(r) + } + return r, nil } // reposForDependencies create temporary repositories.yaml and configure settings with it. -func reposForDependencies(deps []*chart.Dependency, trustAnyRepo *bool, settings *cli.EnvSettings, getters getter.Providers) (repositoryConfig, error) { +func reposForDependencies(deps []*chart.Dependency, repos repositories.Interface) (repositoryConfig, error) { repoURLs := map[string]struct{}{} for _, d := range deps { repoURLs[d.Repository] = struct{}{} } - repos, err := reposForURLs(repoURLs, trustAnyRepo, settings, getters) - if err != nil { - return nil, err - } - return repos, nil -} - -type repositories struct { - filePath string - repos *repo.File - repoURLMap map[string]*repo.Entry - getters getter.Providers - cacheDir string - entriesAdded bool - indexFiles map[string]*repo.IndexFile -} - -func (f *repositories) RequireTempHelmHome(createTemp bool) { - f.entriesAdded = f.entriesAdded || createTemp -} - -func newRepositories(settings *cli.EnvSettings, getters getter.Providers) (r *repositories, err error) { - r = &repositories{ - filePath: settings.RepositoryConfig, - repoURLMap: map[string]*repo.Entry{}, - getters: getters, - cacheDir: settings.RepositoryCache, - indexFiles: map[string]*repo.IndexFile{}, - } - if !filepath.IsAbs(settings.RepositoryConfig) { - return nil, errors.Errorf("path to repositories.yaml must be absolute but was %q", settings.RepositoryConfig) - } - r.repos, err = repo.LoadFile(settings.RepositoryConfig) - if err != nil { - if !os.IsNotExist(errors.Cause(err)) { - return nil, errors.Wrapf(err, "load %s", settings.RepositoryConfig) - } - r.repos = nil - } else { - for _, e := range r.repos.Repositories { - r.repoURLMap[e.URL] = e - } - } - if err = os.MkdirAll(settings.RepositoryCache, 0750); err != nil { - return nil, errors.Wrap(err, "create repo cache dir") - } - return r, nil -} - -func (f *repositories) repoIndex(ctx context.Context, entry *repo.Entry) (*repo.IndexFile, error) { - idx := f.indexFiles[entry.Name] - if idx != nil { - return idx, nil - } - idxFile := indexFile(entry, f.cacheDir) - idx, err := loadIndexFile(ctx, idxFile) - if err != nil { - if os.IsNotExist(errors.Cause(err)) { - err = downloadIndexFile(ctx, entry, f.cacheDir, f.getters) - if err != nil { - return nil, err - } - idx, err = loadIndexFile(ctx, idxFile) - if err != nil { - return nil, err - } - } else { - return nil, err - } - } - f.indexFiles[entry.Name] = idx - return idx, nil + return reposForURLs(repoURLs, repos) } -func loadIndexFile(ctx context.Context, idxFile string) (idx *repo.IndexFile, err error) { - done := make(chan struct{}, 1) - go func() { - idx, err = repo.LoadIndexFile(idxFile) - close(done) - }() - select { - case <-done: - return idx, errors.Wrapf(err, "load repo index file %s", idxFile) - case <-ctx.Done(): - return nil, errors.Wrapf(ctx.Err(), "load repo index file %s", idxFile) - } +type requiredRepos struct { + repositories.Interface + repoFile *repo.File } -func (f *repositories) clearRepoIndex(entry *repo.Entry) { - f.indexFiles[entry.Name] = nil -} - -func (f *repositories) ResolveChartVersion(ctx context.Context, name, version, repoURL string) (*repo.ChartVersion, error) { - entry, err := f.Get(repoURL) - if err != nil { - return nil, err - } - idx, err := f.repoIndex(ctx, entry) +func (r *requiredRepos) TempRepositories() (repositoryConfig, error) { + tr, err := newTempRepositories(r) if err != nil { - return nil, err - } - errMsg := fmt.Sprintf("chart %q", name) - if version != "" { - errMsg = fmt.Sprintf("%s version %q", errMsg, version) - } - cv, err := idx.Get(name, version) - if err != nil { - // Download latest index file and retry lookup if not found - err = downloadIndexFile(ctx, entry, f.cacheDir, f.getters) - if err != nil { - return nil, errors.Wrapf(err, "repo index download after %s not found", errMsg) - } - f.clearRepoIndex(entry) - idx, err := f.repoIndex(ctx, entry) - if err != nil { - return nil, err - } - cv, err = idx.Get(name, version) - if err != nil { - return nil, errors.Errorf("%s not found in repository %s", errMsg, entry.URL) - } - } - - if len(cv.URLs) == 0 { - return nil, errors.Errorf("%s has no downloadable URLs", errMsg) - } - return cv, nil -} - -func (f *repositories) FilePath() string { - return f.filePath -} - -func (f *repositories) Apply() (repositoryConfig, error) { - if !f.entriesAdded { - return f, nil // don't create temp repos + return nil, fmt.Errorf("new temp repositories: %w", err) } - return newTempRepositories(f) + return tr, nil } -func (f *repositories) Close() error { +func (_ *requiredRepos) Close() error { return nil } -func (f *repositories) Get(repo string) (*repo.Entry, error) { - isName := false - if strings.HasPrefix(repo, "alias:") { - repo = repo[6:] - isName = true - } - if strings.HasPrefix(repo, "@") { - repo = repo[1:] - isName = true - } - if isName && f.repos != nil { - if entry := f.repos.Get(repo); entry != nil { - return entry, nil - } - return nil, errors.Errorf("repo name %q is not registered in repositories.yaml", repo) - } - if entry := f.repoURLMap[repo]; entry != nil { - return entry, nil - } - return nil, errors.Errorf("repo URL %q is not registered in repositories.yaml", repo) -} - -func (f *repositories) DownloadIndexFilesIfNotExist(ctx context.Context) error { - for _, r := range f.repos.Repositories { - if _, err := os.Stat(indexFile(r, f.cacheDir)); err == nil { - continue // do not update existing repo index - } - if err := downloadIndexFile(ctx, r, f.cacheDir, f.getters); err != nil { - return errors.Wrap(err, "download repo index") +func (r *requiredRepos) FetchMissingIndexFiles(ctx context.Context) error { + for _, entry := range r.repoFile.Repositories { + err := r.Interface.FetchIndexIfNotExist(ctx, entry) + if err != nil { + return err } } return nil } -func (f *repositories) UpdateIndex(ctx context.Context) error { - for _, r := range f.repos.Repositories { - if err := downloadIndexFile(ctx, r, f.cacheDir, f.getters); err != nil { - return errors.Wrap(err, "download repo index") +func (r *requiredRepos) UpdateIndex(ctx context.Context) error { + for _, entry := range r.repoFile.Repositories { + err := r.Interface.UpdateIndex(ctx, entry) + if err != nil { + return err } } return nil } -func (f *repositories) setRepositoriesFromURLs(repoURLs map[string]struct{}, trustAnyRepo *bool) error { - requiredRepos := make([]*repo.Entry, 0, len(repoURLs)) - repoURLMap := map[string]*repo.Entry{} - for u := range repoURLs { - repo, _ := f.Get(u) - if repo != nil { - u = repo.URL - } else if strings.HasPrefix(u, "alias:") || strings.HasPrefix(u, "@") { - return errors.Errorf("repository %q not found in repositories.yaml", u) - } else if trustAnyRepo != nil && !*trustAnyRepo || trustAnyRepo == nil && f.repos != nil { - err := errors.Errorf("repository %q not found in %s and usage of untrusted repositories is disabled", u, f.filePath) - if f.repos == nil { - err = errors.Errorf("request repository %q: %s does not exist and usage of untrusted repositories is disabled", u, f.filePath) - } - return &untrustedRepoError{err} - } - repoURLMap[u] = repo - } - if f.repos != nil { - for _, entry := range f.repos.Repositories { - if repo := repoURLMap[entry.URL]; repo != nil { - requiredRepos = append(requiredRepos, repo) - } - } +func repoFileFromURLs(repoURLSet map[string]struct{}, repos repositories.Interface) (*repo.File, bool, error) { + repoURLMap := make(map[string]*repo.Entry, len(repoURLSet)) + repoURLs := make([]string, 0, len(repoURLMap)) + for k := range repoURLSet { + repoURLs = append(repoURLs, k) } - f.repos = repo.NewFile() - f.repos.Repositories = requiredRepos - newURLs := make([]string, 0, len(repoURLMap)) - for u, knownRepo := range repoURLMap { - if knownRepo == nil { - newURLs = append(newURLs, u) - f.entriesAdded = true - } - } - sort.Strings(newURLs) - for _, repoURL := range newURLs { - if _, err := f.addRepositoryURL(repoURL); err != nil { - return err + sort.Strings(repoURLs) + untrusted := false + newRepos := repo.NewFile() + for _, u := range repoURLs { + entry, found, err := repos.Get(u) + if err != nil { + return nil, false, err } - } - - // Log repository usage - repoUsage := make([]string, len(f.repos.Repositories)) - for i, entry := range f.repos.Repositories { - if repo := repoURLMap[entry.URL]; repo != nil || trustAnyRepo != nil { + newRepos.Add(entry) + if found { authInfo := "unauthenticated" if entry.Username != "" && entry.Password != "" { authInfo = fmt.Sprintf("as user %q", entry.Username) } - repoUsage[i] = fmt.Sprintf("Using repository %q (%s)", entry.URL, authInfo) + log.Printf("Using repository %q (%s)", u, authInfo) } else { - repoUsage[i] = fmt.Sprintf("WARNING: using untrusted repository %q", entry.URL) + untrusted = true + log.Printf("WARNING: using untrusted repository %q", u) } } - sort.Strings(repoUsage) - for _, msg := range repoUsage { - log.Println(msg) - } - - return nil -} - -func (f *repositories) addRepositoryURL(repoURL string) (*repo.Entry, error) { - for _, repo := range f.repos.Repositories { - f.repoURLMap[repo.URL] = repo - } - name, err := urlToHash(repoURL) - if err != nil { - return nil, err - } - if existing := f.repoURLMap[repoURL]; existing != nil { - return existing, nil - } - entry := &repo.Entry{ - Name: name, - URL: repoURL, - } - f.repos.Add(entry) - f.repoURLMap[entry.URL] = entry - f.entriesAdded = true - return entry, nil + return newRepos, untrusted, nil } type tempRepositories struct { - *repositories + *requiredRepos tmpFile string } -func newTempRepositories(r *repositories) (*tempRepositories, error) { +func newTempRepositories(r *requiredRepos) (*tempRepositories, error) { tmpFile, err := os.CreateTemp("", "helm-repositories-") if err != nil { return nil, errors.WithStack(err) } _ = tmpFile.Close() - err = r.repos.WriteFile(tmpFile.Name(), 0640) + err = r.repoFile.WriteFile(tmpFile.Name(), 0640) return &tempRepositories{r, tmpFile.Name()}, err } -func (f *tempRepositories) FilePath() string { - return f.tmpFile -} - -func (f *tempRepositories) Close() error { - return os.Remove(f.tmpFile) -} - -func downloadIndexFile(ctx context.Context, entry *repo.Entry, cacheDir string, getters getter.Providers) error { - log.Printf("Downloading repository index of %s", entry.URL) - idxFile := indexFile(entry, cacheDir) - err := os.MkdirAll(filepath.Dir(idxFile), 0750) - if err != nil { - return errors.WithStack(err) - } - tmpIdxFile, err := os.CreateTemp(filepath.Dir(idxFile), fmt.Sprintf(".tmp-%s-index", entry.Name)) - if err != nil { - return errors.WithStack(err) - } - tmpIdxFileName := tmpIdxFile.Name() - _ = tmpIdxFile.Close() - - interrupt := ctx.Done() - done := make(chan error, 1) - go func() { - var err error - defer func() { - done <- err - close(done) - if err != nil { - _ = os.Remove(tmpIdxFileName) - } - }() - tmpEntry := *entry - tmpEntry.Name = filepath.Base(tmpIdxFileName) - r, err := repo.NewChartRepository(&tmpEntry, getters) - if err != nil { - err = errors.WithStack(err) - return - } - r.CachePath = filepath.Dir(tmpIdxFileName) - tmpIdxFileName, err = r.DownloadIndexFile() - if err != nil { - err = errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", entry.URL) - return - } - err = os.Rename(tmpIdxFileName, idxFile) - if os.IsExist(err) { - // Ignore error if file was downloaded by another process concurrently. - // This fixes a race condition, see https://github.com/mgoltzsche/khelm/issues/36 - err = os.Remove(tmpIdxFileName) - } - err = errors.WithStack(err) - }() - select { - case err := <-done: - return err - case <-interrupt: - _ = os.Remove(tmpIdxFileName) - return ctx.Err() - } +func (r *tempRepositories) File() string { + return r.tmpFile } -func indexFile(entry *repo.Entry, cacheDir string) string { - return filepath.Join(cacheDir, helmpath.CacheIndexFile(entry.Name)) +func (r *tempRepositories) Close() error { + return os.Remove(r.tmpFile) } -func urlToHash(str string) (string, error) { - hash := crypto.SHA256.New() - if _, err := io.Copy(hash, bytes.NewReader([]byte(str))); err != nil { - return "", errors.Wrap(err, "urlToHash") - } - name := hex.EncodeToString(hash.Sum(nil)) - return strings.ToLower(strings.TrimRight(name, "=")), nil +func (r *tempRepositories) TempRepositories() (repositoryConfig, error) { + return r, nil } diff --git a/pkg/repositories/repositories.go b/pkg/repositories/repositories.go new file mode 100644 index 0000000..e4f8e6e --- /dev/null +++ b/pkg/repositories/repositories.go @@ -0,0 +1,276 @@ +package repositories + +import ( + "bytes" + "context" + "crypto" + "encoding/hex" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/repo" +) + +type untrustedRepoError struct { + error +} + +func IsUntrustedRepository(err error) bool { + var urErr *untrustedRepoError + return errors.As(err, &urErr) +} + +type Interface interface { + File() string + ResolveChartVersion(ctx context.Context, name, version, repoURL string) (*repo.ChartVersion, error) + Get(repo string) (*repo.Entry, bool, error) + FetchIndexIfNotExist(ctx context.Context, entry *repo.Entry) error + UpdateIndex(ctx context.Context, entry *repo.Entry) error +} + +type repositories struct { + file string + repos *repo.File + repoURLMap map[string]*repo.Entry + getters getter.Providers + cacheDir string + indexFiles map[string]*repo.IndexFile + trustAnyRepo *bool +} + +func New(settings cli.EnvSettings, getters getter.Providers, trustAnyRepo *bool) (Interface, error) { + if !filepath.IsAbs(settings.RepositoryConfig) { + return nil, fmt.Errorf("path to repositories.yaml must be absolute but was %q", settings.RepositoryConfig) + } + repoURLMap := map[string]*repo.Entry{} + repos, err := repo.LoadFile(settings.RepositoryConfig) + if err != nil { + if !os.IsNotExist(errors.Unwrap(errors.Unwrap(err))) { + return nil, fmt.Errorf("load repo config: %w", err) + } + repos = nil + } else { + for _, e := range repos.Repositories { + repoURLMap[e.URL] = e + } + } + if err = os.MkdirAll(settings.RepositoryCache, 0750); err != nil { + return nil, fmt.Errorf("create repo cache dir: %w", err) + } + return &repositories{ + file: settings.RepositoryConfig, + repos: repos, + repoURLMap: repoURLMap, + getters: getters, + cacheDir: settings.RepositoryCache, + indexFiles: map[string]*repo.IndexFile{}, + trustAnyRepo: trustAnyRepo, + }, nil +} + +func (r *repositories) File() string { + return r.file +} + +func (r *repositories) ResolveChartVersion(ctx context.Context, name, version, repoURL string) (*repo.ChartVersion, error) { + entry, _, err := r.Get(repoURL) + if err != nil { + return nil, err + } + idx, err := r.index(ctx, entry) + if err != nil { + return nil, err + } + errMsg := fmt.Sprintf("chart %q", name) + if version != "" { + errMsg = fmt.Sprintf("%s version %q", errMsg, version) + } + cv, err := idx.Get(name, version) + if err != nil { + // Download latest index file and retry lookup if not found + idx, err := r.update(ctx, entry) + if err != nil { + return nil, fmt.Errorf("%s not found in repository %s: %w", errMsg, entry.URL, err) + } + cv, err = idx.Get(name, version) + if err != nil { + return nil, fmt.Errorf("%s not found in repository %s", errMsg, entry.URL) + } + } + if len(cv.URLs) == 0 { + return nil, fmt.Errorf("%s has no downloadable URLs", errMsg) + } + return cv, nil +} + +func (r *repositories) Get(repo string) (*repo.Entry, bool, error) { + isName := false + if strings.HasPrefix(repo, "alias:") { + repo = repo[6:] + isName = true + } + if strings.HasPrefix(repo, "@") { + repo = repo[1:] + isName = true + } + if isName { + if r.repos == nil { + return nil, false, fmt.Errorf("resolve repo name %q: no repositories.yaml configured to resolve repo alias with", repo) + } + if e := r.repos.Get(repo); e != nil { + entry := *e + return &entry, true, nil + } + return nil, false, fmt.Errorf("repo name %q is not registered in repositories.yaml", repo) + } + if entry := r.repoURLMap[repo]; entry != nil { + return entry, true, nil + } + if r.trustAnyRepo != nil && !*r.trustAnyRepo || r.trustAnyRepo == nil && r.repos != nil { + err := fmt.Errorf("repository %q not found in %s and usage of untrusted repositories is disabled", repo, r.file) + if r.repos == nil { + err = fmt.Errorf("use repository %s: configuration %s does not exist and usage of untrusted repositories is disabled", repo, r.file) + } + return nil, false, &untrustedRepoError{err} + } + return newEntry(repo), false, nil +} + +func (r *repositories) FetchIndexIfNotExist(ctx context.Context, entry *repo.Entry) error { + _, err := r.index(ctx, entry) + return err +} + +func (r *repositories) index(ctx context.Context, entry *repo.Entry) (*repo.IndexFile, error) { + if idx := r.indexFiles[entry.Name]; idx != nil { + return idx, nil + } + idxFile := indexFile(entry, r.cacheDir) + idx, err := loadIndexFile(ctx, idxFile) + if err != nil { + if os.IsNotExist(errors.Unwrap(err)) { + return r.update(ctx, entry) + } + } + return idx, err +} + +func (r *repositories) UpdateIndex(ctx context.Context, entry *repo.Entry) error { + _, err := r.update(ctx, entry) + return err +} + +func (r *repositories) update(ctx context.Context, entry *repo.Entry) (*repo.IndexFile, error) { + err := downloadIndexFile(ctx, entry, r.cacheDir, r.getters) + if err != nil { + return nil, fmt.Errorf("download index for repo %s: %w", entry.URL, err) + } + idxFile := indexFile(entry, r.cacheDir) + idx, err := loadIndexFile(ctx, idxFile) + if err != nil { + return nil, err + } + r.indexFiles[entry.Name] = idx + return idx, nil +} + +func newEntry(repoURL string) *repo.Entry { + name, err := urlToHash(repoURL) + if err != nil { + panic(fmt.Errorf("hash repo url: %w", err)) + } + return &repo.Entry{ + Name: name, + URL: repoURL, + } +} + +func urlToHash(str string) (string, error) { + hash := crypto.SHA256.New() + if _, err := io.Copy(hash, bytes.NewReader([]byte(str))); err != nil { + return "", err + } + name := hex.EncodeToString(hash.Sum(nil)) + return strings.ToLower(strings.TrimRight(name, "=")), nil +} + +func loadIndexFile(ctx context.Context, idxFile string) (idx *repo.IndexFile, err error) { + done := make(chan struct{}, 1) + go func() { + idx, err = repo.LoadIndexFile(idxFile) + close(done) + }() + select { + case <-done: + if err != nil { + return nil, fmt.Errorf("load repo index file %s: %w", idxFile, err) + } + return idx, nil + case <-ctx.Done(): + return nil, fmt.Errorf("load repo index file %s: %w", idxFile, ctx.Err()) + } +} + +func downloadIndexFile(ctx context.Context, entry *repo.Entry, cacheDir string, getters getter.Providers) error { + log.Printf("Downloading repository index of %s", entry.URL) + idxFile := indexFile(entry, cacheDir) + err := os.MkdirAll(filepath.Dir(idxFile), 0750) + if err != nil { + return err + } + tmpIdxFile, err := os.CreateTemp(filepath.Dir(idxFile), fmt.Sprintf(".tmp-%s-index", entry.Name)) + if err != nil { + return err + } + tmpIdxFileName := tmpIdxFile.Name() + _ = tmpIdxFile.Close() + + interrupt := ctx.Done() + done := make(chan error, 1) + go func() { + var err error + defer func() { + done <- err + close(done) + if err != nil { + _ = os.Remove(tmpIdxFileName) + } + }() + tmpEntry := *entry + tmpEntry.Name = filepath.Base(tmpIdxFileName) + r, e := repo.NewChartRepository(&tmpEntry, getters) + if e != nil { + err = e + return + } + r.CachePath = filepath.Dir(tmpIdxFileName) + tmpIdxFileName, err = r.DownloadIndexFile() + if err != nil { + err = fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", entry.URL, err) + return + } + err = os.Rename(tmpIdxFileName, idxFile) + if os.IsExist(err) { + err = os.Remove(tmpIdxFileName) + } + }() + select { + case err := <-done: + return err + case <-interrupt: + _ = os.Remove(tmpIdxFileName) + return ctx.Err() + } +} + +func indexFile(entry *repo.Entry, cacheDir string) string { + return filepath.Join(cacheDir, helmpath.CacheIndexFile(entry.Name)) +} diff --git a/pkg/repositories/repositories_test.go b/pkg/repositories/repositories_test.go new file mode 100644 index 0000000..235f9eb --- /dev/null +++ b/pkg/repositories/repositories_test.go @@ -0,0 +1,258 @@ +package repositories + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/repo" + "sigs.k8s.io/yaml" +) + +func init() { + log.SetFlags(0) +} + +func TestRepositories(t *testing.T) { + dir, err := os.MkdirTemp("", "khelm-test-repositories-") + require.NoError(t, err) + defer os.RemoveAll(dir) + repoURL := "fake://example.org/myorg/myrepo@some/path?ref=v1.2.3" + repoConfPath := filepath.Join(dir, "repositories.yaml") + repoConf := repo.NewFile() + repoConf.Add(&repo.Entry{ + Name: "fake-repo", + URL: repoURL, + Username: "fake-user", + Password: "fake-password", + }) + b, err := yaml.Marshal(repoConf) + require.NoError(t, err) + err = os.WriteFile(repoConfPath, b, 0600) + require.NoError(t, err) + settings := cli.New() + settings.RepositoryCache = filepath.Join(dir, "cache") + settings.RepositoryConfig = "/fake/non-existing/repositories.yaml" + chartURL := "fake://example.org/myorg/myrepo@some/path/mychart.tgz?ref=v1.2.3" + g := &fakeGetter{ + indexURL: "fake://example.org/myorg/myrepo@some/path/index.yaml?ref=v1.2.3", + chartURL: chartURL, + index: fakeIndexFile(chartURL), + } + trust := true + testee := newTestee(t, settings, &trust, g) + t.Run("File should return repositories config path", func(t *testing.T) { + file := testee.File() + require.Equal(t, settings.RepositoryConfig, file, "File()") + _, err = os.Stat(file) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + }) + t.Run("Get should resolve untrusted repo when trust-any enabled", func(t *testing.T) { + entry, trusted, err := testee.Get(repoURL) + require.NoError(t, err, "Get()") + require.NotNil(t, entry, "entry returned by Get()") + require.False(t, trusted, "trusted") + }) + t.Run("Get should not resolve untrusted repo", func(t *testing.T) { + newSettings := *settings + newSettings.RepositoryConfig = repoConfPath + trust := false + testee := newTestee(t, &newSettings, &trust, g) + entry, trusted, err := testee.Get(repoURL + "-sth") + require.Error(t, err, "Get()") + require.Truef(t, IsUntrustedRepository(err), "error should indicate the repo is untrusted but was: %s", err) + require.False(t, trusted, "trusted") + require.Nil(t, entry, "entry returned by Get()") + }) + var entry *repo.Entry + t.Run("Get should return credentials for trusted repo", func(t *testing.T) { + newSettings := *settings + newSettings.RepositoryConfig = repoConfPath + trust := false + testee := newTestee(t, &newSettings, &trust, g) + var err error + var trusted bool + entry, trusted, err = testee.Get(repoURL) + require.NoError(t, err, "Get()") + require.NotNil(t, entry, "entry returned by Get()") + require.True(t, trusted, "trusted") + require.Equal(t, "fake-user", entry.Username, "username") + require.Equal(t, "fake-password", entry.Password, "password") + }) + t.Run("FetchIndexIfNotExist should fetch when file does not exist", func(t *testing.T) { + err = testee.FetchIndexIfNotExist(context.Background(), entry) + require.NoError(t, err, "FetchIndexIfNotExist()") + idxFilePath := filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(entry.Name)) + b, err := os.ReadFile(idxFilePath) + require.NoError(t, err, "read downloaded repo index file") + idx := repo.NewIndexFile() + err = yaml.Unmarshal(b, idx) + require.NoError(t, err, "unmarshal repo index file") + require.Equal(t, g.index, idx, "repo index") + }) + t.Run("FetchIndexIfNotExist should not fetch when file exists", func(t *testing.T) { + origIdx := g.index + g.index = fakeIndexFile(chartURL) + g.index.Entries["fakechart"][0].Version = "0.1.1" + err = testee.FetchIndexIfNotExist(context.Background(), entry) + require.NoError(t, err, "FetchIndexIfNotExist()") + idxFilePath := filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(entry.Name)) + b, err := os.ReadFile(idxFilePath) + require.NoError(t, err, "read downloaded repo index file") + idx := repo.NewIndexFile() + err = yaml.Unmarshal(b, idx) + require.NoError(t, err, "unmarshal repo index file") + require.Equal(t, origIdx, idx, "repo index") + }) + t.Run("FetchIndexIfNotExist should fail when index file does not exist", func(t *testing.T) { + newEntry := *entry + newEntry.Name += "-unknown" + newEntry.URL += "-unknown" + err := testee.FetchIndexIfNotExist(context.Background(), &newEntry) + require.Error(t, err) + }) + t.Run("UpdateIndex should update index if file exists", func(t *testing.T) { + g.index.Entries["fakechart"][0].Version = "0.2.0" + err = testee.UpdateIndex(context.Background(), entry) + require.NoError(t, err, "UpdateIndex()") + idxFilePath := filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(entry.Name)) + b, err := os.ReadFile(idxFilePath) + require.NoError(t, err, "read downloaded repo index file") + idx := repo.NewIndexFile() + err = yaml.Unmarshal(b, idx) + require.NoError(t, err, "unmarshal repo index file") + require.Equal(t, g.index, idx, "repo index") + }) + t.Run("UpdateIndex should update index if file does not exist", func(t *testing.T) { + err := os.Remove(filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(entry.Name))) + require.NoError(t, err, "remove previously downloaded repo index file") + err = testee.UpdateIndex(context.Background(), entry) + require.NoError(t, err, "UpdateIndex()") + idxFilePath := filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(entry.Name)) + b, err := os.ReadFile(idxFilePath) + require.NoError(t, err, "read downloaded repo index file") + idx := repo.NewIndexFile() + err = yaml.Unmarshal(b, idx) + require.NoError(t, err, "unmarshal repo index file") + require.Equal(t, g.index, idx, "repo index") + }) + t.Run("ResolveChart should resolve chart version", func(t *testing.T) { + g.index = fakeIndexFile(chartURL) + chartVersion, err := testee.ResolveChartVersion(context.Background(), "fakechart", "0.1.0", repoURL) + require.NoError(t, err) + require.NotNil(t, chartVersion) + require.Equal(t, "fakechart", chartVersion.Name, "name") + require.Equal(t, "0.1.0", chartVersion.Version, "version") + require.Equal(t, []string{chartURL}, chartVersion.URLs, "urls") + }) + t.Run("ResolveChart should resolve chart version range", func(t *testing.T) { + g.index = fakeIndexFile(chartURL) + chartVersion, err := testee.ResolveChartVersion(context.Background(), "fakechart", "0.x.x", repoURL) + require.NoError(t, err) + require.NotNil(t, chartVersion) + require.Equal(t, "fakechart", chartVersion.Name, "name") + require.Equal(t, "0.1.0", chartVersion.Version, "version") + require.Equal(t, []string{chartURL}, chartVersion.URLs, "urls") + }) + t.Run("ResolveChart should resolve empty chart version", func(t *testing.T) { + g.index = fakeIndexFile(chartURL) + chartVersion, err := testee.ResolveChartVersion(context.Background(), "fakechart", "", repoURL) + require.NoError(t, err) + require.NotNil(t, chartVersion) + require.Equal(t, "fakechart", chartVersion.Name, "name") + require.Equal(t, "0.1.0", chartVersion.Version, "version") + require.Equal(t, []string{chartURL}, chartVersion.URLs, "urls") + }) + t.Run("ResolveChart should resolve repo alias", func(t *testing.T) { + g.index = fakeIndexFile(chartURL) + newSettings := *settings + newSettings.RepositoryConfig = repoConfPath + trust := false + testee := newTestee(t, &newSettings, &trust, g) + chartVersion, err := testee.ResolveChartVersion(context.Background(), "fakechart", "0.1.0", "alias:fake-repo") + require.NoError(t, err) + require.NotNil(t, chartVersion) + require.Equal(t, "fakechart", chartVersion.Name, "name") + require.Equal(t, "0.1.0", chartVersion.Version, "version") + require.Equal(t, []string{chartURL}, chartVersion.URLs, "urls") + }) + t.Run("ResolveChart should resolve repo at alias", func(t *testing.T) { + g.index = fakeIndexFile(chartURL) + newSettings := *settings + newSettings.RepositoryConfig = repoConfPath + trust := false + testee := newTestee(t, &newSettings, &trust, g) + chartVersion, err := testee.ResolveChartVersion(context.Background(), "fakechart", "0.1.0", "@fake-repo") + require.NoError(t, err) + require.NotNil(t, chartVersion) + require.Equal(t, "fakechart", chartVersion.Name, "name") + require.Equal(t, "0.1.0", chartVersion.Version, "version") + require.Equal(t, []string{chartURL}, chartVersion.URLs, "urls") + }) + t.Run("ResolveChart should fail when chart does not exist", func(t *testing.T) { + g.index = fakeIndexFile(chartURL) + _, err := testee.ResolveChartVersion(context.Background(), "fakechart", "1.x.x", repoURL) + require.Error(t, err) + }) +} + +func newTestee(t *testing.T, settings *cli.EnvSettings, trust *bool, g *fakeGetter) Interface { + fakeGetterConstructor := func(_ ...getter.Option) (getter.Getter, error) { return g, nil } + r, err := New(*settings, getter.Providers([]getter.Provider{{ + Schemes: []string{"fake"}, + New: fakeGetterConstructor, + }}), trust) + require.NoError(t, err) + return r +} + +func fakeIndexFile(chartURL string) *repo.IndexFile { + return &repo.IndexFile{ + APIVersion: "v2", + Entries: map[string]repo.ChartVersions{ + "fakechart": []*repo.ChartVersion{ + { + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "fakechart", + Version: "0.1.0", + }, + URLs: []string{chartURL}, + }, + }, + }, + PublicKeys: []string{}, + } +} + +type fakeGetter struct { + indexURL string + chartURL string + index *repo.IndexFile +} + +func (g *fakeGetter) Get(location string, _ ...getter.Option) (*bytes.Buffer, error) { + var buf bytes.Buffer + if location == g.indexURL { + b, err := yaml.Marshal(g.index) + if err != nil { + panic(err) + } + _, _ = buf.Write(b) + return &buf, nil + } else if location == g.chartURL { + _, _ = buf.WriteString("fake chart tgz") + return &buf, nil + } + return nil, fmt.Errorf("unexpected location %q", location) +}