├── testdata ├── scenarios │ ├── plain-chart │ │ ├── Chart.yaml.tmpl │ │ └── values.yaml.tmpl │ ├── complete-chart │ │ ├── Images.lock.tmpl │ │ ├── images.partial.tmpl │ │ ├── Chart.yaml.tmpl │ │ └── imagelock.partial.tmpl │ ├── no-images-chart │ │ ├── Images.lock.tmpl │ │ ├── imagelock.partial.tmpl │ │ └── Chart.yaml.tmpl │ ├── chart1 │ │ ├── values.yaml.tmpl │ │ ├── values.prod.yaml.tmpl │ │ ├── charts │ │ │ ├── mariadb │ │ │ │ ├── Chart.lock │ │ │ │ └── Chart.yaml.tmpl │ │ │ └── common │ │ │ │ └── Chart.yaml │ │ ├── images.partial.tmpl │ │ ├── Images.lock.tmpl │ │ ├── Chart.yaml.tmpl │ │ └── lock_images.partial.tmpl │ ├── custom-chart │ │ ├── images.partial.tmpl │ │ ├── .imgpkg │ │ │ ├── bundle.yml.tmpl │ │ │ └── images.yml.tmpl │ │ ├── imagelock.partial.tmpl │ │ └── Chart.yaml.tmpl │ └── recursive-chart │ │ ├── charts │ │ └── chartB │ │ │ ├── charts │ │ │ └── chartC │ │ │ │ └── Chart.yaml │ │ │ └── Chart.yaml │ │ └── Chart.yaml └── images.json ├── demo.gif ├── .gitignore ├── cmd └── dt │ ├── version_test.go │ ├── dt.go │ ├── chart_test.go │ ├── auth.go │ ├── images_test.go │ ├── chart.go │ ├── images.go │ ├── version.go │ ├── auth_test.go │ ├── annotate │ └── annotate.go │ ├── root.go │ ├── logout │ └── logout.go │ ├── relocate │ └── relocate.go │ ├── dt_test.go │ ├── lock_test.go │ ├── carvelize_test.go │ ├── pull_test.go │ ├── push │ └── push.go │ ├── info │ └── info.go │ ├── login │ └── login.go │ ├── lock │ └── lock.go │ ├── verify │ └── verify.go │ ├── annotate_test.go │ ├── config │ └── config.go │ ├── relocate_test.go │ ├── pull │ └── pull.go │ ├── verify_test.go │ ├── info_test.go │ ├── push_test.go │ └── carvelize │ └── carvelize.go ├── internal ├── widgets │ ├── constants.go │ ├── interactive.go │ └── spinner.go └── testutil │ ├── utils.go │ ├── assert_test.go │ ├── assert.go │ ├── sandbox.go │ ├── server.go │ └── cosign.go ├── plugin.yaml ├── NOTICE ├── pkg ├── relocator │ ├── relocator_test.go │ ├── annotations_test.go │ ├── annotations.go │ ├── imagelock.go │ ├── values.go │ └── options.go ├── imagelock │ ├── image_test.go │ ├── options.go │ └── digest.go ├── log │ ├── section_logger.go │ ├── silent │ │ ├── logger.go │ │ ├── section_logger.go │ │ └── progress.go │ ├── logrus │ │ ├── logger.go │ │ └── section_logger.go │ ├── pterm │ │ ├── printers.go │ │ ├── logger.go │ │ ├── section_logger.go │ │ └── progress.go │ ├── progress.go │ └── logger.go ├── utils │ ├── copy.go │ └── utils.go ├── chartutils │ ├── chartutils_test.go │ ├── options.go │ ├── values_test.go │ ├── chart_test.go │ ├── images_test.go │ └── chart.go ├── wrapping │ └── wrap.go └── carvel │ └── carvel.go ├── .goreleaser.yaml ├── .github └── workflows │ ├── prepare-release.yaml │ ├── ci.yaml │ └── release.yaml ├── .golangci.yml ├── Makefile ├── CONTRIBUTING_CLA.md └── install-binary.sh /testdata/scenarios/plain-chart/Chart.yaml.tmpl: -------------------------------------------------------------------------------- 1 | name: wordpress 2 | version: 1.0.0 3 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vmware-labs/distribution-tooling-for-helm/main/demo.gif -------------------------------------------------------------------------------- /testdata/scenarios/complete-chart/Images.lock.tmpl: -------------------------------------------------------------------------------- 1 | {{include "imagelock.partial.tmpl" . }} -------------------------------------------------------------------------------- /testdata/scenarios/no-images-chart/Images.lock.tmpl: -------------------------------------------------------------------------------- 1 | {{include "imagelock.partial.tmpl" . }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /dist/ 3 | /examples/ 4 | /.vscode/ 5 | /.idea 6 | **~ 7 | **.tgz 8 | /out/ 9 | **/#* 10 | **/.#* 11 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/values.yaml.tmpl: -------------------------------------------------------------------------------- 1 | image: 2 | registry: {{.ServerURL}} 3 | repository: {{if .RepositoryPrefix}}{{.RepositoryPrefix}}/{{end}}bitnami/wordpress 4 | tag: 6.2.2-debian-11-r26 5 | -------------------------------------------------------------------------------- /testdata/scenarios/custom-chart/images.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{- $p := . -}} 2 | {{- range .Images}} 3 | - name: {{.Name}} 4 | image: {{if $p.RepositoryURL}}{{$p.RepositoryURL}}/{{end}}{{.Image}} 5 | {{- end }} 6 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/values.prod.yaml.tmpl: -------------------------------------------------------------------------------- 1 | image: 2 | registry: {{.ServerURL}} 3 | repository: {{if .RepositoryPrefix}}{{.RepositoryPrefix}}/{{end}}bitnami/wordpress 4 | tag: 6.2.2-debian-11-r26 5 | -------------------------------------------------------------------------------- /testdata/scenarios/complete-chart/images.partial.tmpl: -------------------------------------------------------------------------------- 1 | {{- $p := . -}} 2 | {{- range .Images}} 3 | - name: {{.Name}} 4 | image: {{if $p.RepositoryURL}}{{$p.RepositoryURL}}/{{end}}{{.Image}} 5 | {{- end }} 6 | -------------------------------------------------------------------------------- /testdata/scenarios/plain-chart/values.yaml.tmpl: -------------------------------------------------------------------------------- 1 | {{if .ValuesImages}} 2 | {{range .ValuesImages}} 3 | {{.Name}}: 4 | registry: {{.Registry}} 5 | repository: {{.Repository}} 6 | tag: {{.Tag}} 7 | {{end}} 8 | {{end}} 9 | -------------------------------------------------------------------------------- /cmd/dt/version_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func (suite *CmdSuite) TestVersionCommand() { 6 | dt("version").AssertSuccessMatch(suite.T(), fmt.Sprintf("^Distribution Tooling for Helm %s", Version)) 7 | } 8 | -------------------------------------------------------------------------------- /internal/widgets/constants.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | const ( 4 | // TerminalSpacer is a text to print to terminal to separate sections to improve readability 5 | // An empty string will just add a new line 6 | TerminalSpacer = "" 7 | ) 8 | -------------------------------------------------------------------------------- /testdata/scenarios/recursive-chart/charts/chartB/charts/chartC/Chart.yaml: -------------------------------------------------------------------------------- 1 | name: chartC 2 | version: 3.0.0 3 | annotations: 4 | category: CMS 5 | licenses: Apache-2.0 6 | images: | 7 | - name: imagec 8 | image: registry-1.docker.io/bitnami/imagec:1.0.8-debian-12-r7 9 | -------------------------------------------------------------------------------- /plugin.yaml: -------------------------------------------------------------------------------- 1 | name: "dt" 2 | version: "0.4.12" 3 | usage: "Distribution Tooling for Helm" 4 | description: "Distribution Tooling for Helm" 5 | command: "$HELM_PLUGIN_DIR/bin/dt" 6 | hooks: 7 | install: "$HELM_PLUGIN_DIR/install-binary.sh" 8 | update: "$HELM_PLUGIN_DIR/install-binary.sh -u" 9 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/charts/mariadb/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: common 3 | repository: oci://registry-1.docker.io/bitnamicharts 4 | version: 2.4.0 5 | digest: sha256:8c1a5dc923412d11d4d841420494b499cb707305c8b9f87f45ea1a8bf3172cb3 6 | generated: "2023-05-21T18:46:17.326179513Z" 7 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/images.partial.tmpl: -------------------------------------------------------------------------------- 1 | - name: wordpress 2 | image: {{.ServerURL}}/bitnami/wordpress:6.2.2-debian-11-r11 3 | - name: bitnami-shell 4 | image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r124 5 | - name: apache-exporter 6 | image: {{.ServerURL}}/bitnami/apache-exporter:0.13.4-debian-11-r2 7 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/Images.lock.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: v0 2 | kind: ImagesLock 3 | metadata: 4 | generatedAt: "2023-07-13T16:30:33.284125307Z" 5 | generatedBy: Distribution Tooling for Helm 6 | chart: 7 | name: wordpress 8 | version: 1.0.0 9 | appVersion: "" 10 | images: 11 | {{include "lock_images.partial.tmpl" . | indent 2 }} 12 | -------------------------------------------------------------------------------- /testdata/scenarios/no-images-chart/imagelock.partial.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: v0 2 | kind: ImagesLock 3 | metadata: 4 | generatedAt: "2023-07-13T16:30:33.284125307Z" 5 | generatedBy: Distribution Tooling for Helm 6 | chart: 7 | name: {{.Name}} 8 | version: 1.0.0 9 | appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} 10 | images: [] 11 | -------------------------------------------------------------------------------- /testdata/scenarios/recursive-chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | name: chartA 2 | version: 1.0.0 3 | annotations: 4 | category: CMS 5 | licenses: Apache-2.0 6 | images: | 7 | - name: imagea 8 | image: registry-1.docker.io/bitnami/imagea:1.0.8-debian-12-r7 9 | 10 | dependencies: 11 | - name: chartB 12 | repository: oci://registry-1.docker.io/bitnamicharts 13 | version: 2.0.0 14 | -------------------------------------------------------------------------------- /testdata/scenarios/recursive-chart/charts/chartB/Chart.yaml: -------------------------------------------------------------------------------- 1 | name: chartB 2 | version: 2.0.0 3 | annotations: 4 | category: CMS 5 | licenses: Apache-2.0 6 | images: | 7 | - name: imageb 8 | image: registry-1.docker.io/bitnami/imageb:1.0.8-debian-12-r7 9 | dependencies: 10 | - name: chartC 11 | repository: oci://registry-1.docker.io/bitnamicharts 12 | version: 3.0.0 13 | -------------------------------------------------------------------------------- /testdata/scenarios/custom-chart/.imgpkg/bundle.yml.tmpl: -------------------------------------------------------------------------------- 1 | version: 2 | apiversion: imgpkg.carvel.dev/v1alpha1 3 | kind: Bundle 4 | metadata: 5 | category: CMS 6 | licenses: Apache-2.0 7 | name: {{or .Name "WordPress"}} 8 | authors: 9 | {{- range .Authors}} 10 | - name: {{.Name}} 11 | email: {{.Email}} 12 | {{end -}} 13 | websites: 14 | {{- range .Websites}} 15 | - url: {{.URL}} 16 | {{end -}} 17 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 VMware, Inc. 2 | 3 | This product is licensed to you under the Apache License, V2.0 (the "License"). You may not use this product except in compliance with the License. 4 | 5 | This product may include a number of subcomponents with separate copyright notices and license terms. Your use of these subcomponents is subject to the terms and conditions of the subcomponent's license, as noted in the LICENSE file. -------------------------------------------------------------------------------- /internal/widgets/interactive.go: -------------------------------------------------------------------------------- 1 | // Package widgets provides a set of reusable widgets for the distribution-tooling-for-helm CLI 2 | package widgets 3 | 4 | import ( 5 | "github.com/pterm/pterm" 6 | ) 7 | 8 | // ShowYesNoQuestion shows the yes/no question message provided 9 | func ShowYesNoQuestion(question string) bool { 10 | result, _ := pterm.DefaultInteractiveConfirm.Show(question) 11 | return result 12 | } 13 | -------------------------------------------------------------------------------- /cmd/dt/dt.go: -------------------------------------------------------------------------------- 1 | // Package main implements the dt tool 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 9 | ) 10 | 11 | func main() { 12 | // Make sure we clean up after ourselves 13 | defer config.CleanGlobalTempWorkDir() 14 | 15 | if err := rootCmd.Execute(); err != nil { 16 | fmt.Fprintln(os.Stderr, err) 17 | os.Exit(1) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/charts/common/Chart.yaml: -------------------------------------------------------------------------------- 1 | annotations: 2 | category: Infrastructure 3 | licenses: Apache-2.0 4 | apiVersion: v2 5 | appVersion: 2.6.0 6 | description: A Library Helm chart for grouping common logic between bitnami charts. 7 | This chart is not deployable by itself. 8 | home: https://bitnami.com 9 | icon: https://bitnami.com/downloads/logos/bitnami-mark.png 10 | name: common 11 | type: library 12 | version: 2.6.0 13 | -------------------------------------------------------------------------------- /pkg/relocator/relocator_test.go: -------------------------------------------------------------------------------- 1 | package relocator 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "testing" 7 | 8 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 9 | ) 10 | 11 | var ( 12 | sb *tu.Sandbox 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | 17 | sb = tu.NewSandbox() 18 | c := m.Run() 19 | 20 | if err := sb.Cleanup(); err != nil { 21 | log.Printf("WARN: failed to cleanup test sandbox: %v", err) 22 | } 23 | 24 | os.Exit(c) 25 | } 26 | -------------------------------------------------------------------------------- /testdata/scenarios/custom-chart/.imgpkg/images.yml.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: imgpkg.carvel.dev/v1alpha1 2 | images: 3 | {{- $p := . -}} 4 | {{- range $idx, $elem := .Images}} 5 | {{ $imageParts := split ":" $elem.Image }} 6 | {{ $img := $imageParts._0 }} 7 | {{- range .Digests}} 8 | {{- if eq .Arch "linux/amd64"}} 9 | - annotations: 10 | kbld.carvel.dev/id: {{$p.ServerURL}}/{{$elem.Image}} 11 | image: {{$p.ServerURL}}/{{$img}}@{{.Digest}} 12 | {{- end }} 13 | {{- end}} 14 | {{- end}} 15 | kind: ImagesLock -------------------------------------------------------------------------------- /testdata/scenarios/chart1/Chart.yaml.tmpl: -------------------------------------------------------------------------------- 1 | name: wordpress 2 | version: 1.0.0 3 | annotations: 4 | category: CMS 5 | licenses: Apache-2.0 6 | {{if .AnnotationsKey}}{{.AnnotationsKey}}{{else}}images{{end}}: | 7 | {{include "images.partial.tmpl" . | indent 6 }} 8 | dependencies: 9 | - name: mariadb 10 | repository: oci://registry-1.docker.io/bitnamicharts 11 | version: 12.x.x 12 | - name: common 13 | repository: oci://registry-1.docker.io/bitnamicharts 14 | version: 2.x.x 15 | -------------------------------------------------------------------------------- /testdata/scenarios/no-images-chart/Chart.yaml.tmpl: -------------------------------------------------------------------------------- 1 | name: {{or .Name "WordPress"}} 2 | version: {{or .Version "1.0.0"}} 3 | annotations: 4 | category: CMS 5 | licenses: Apache-2.0 6 | appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} 7 | {{if .Dependencies }} 8 | {{if gt (len .Dependencies) 0 }} 9 | dependencies: 10 | {{- range .Dependencies}} 11 | - name: {{.Name}} 12 | repository: {{.Repository}} 13 | version: {{.Version}} 14 | {{end -}} 15 | {{end}} 16 | {{end}} 17 | -------------------------------------------------------------------------------- /cmd/dt/chart_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func (suite *CmdSuite) TestChartsHelp() { 9 | t := suite.T() 10 | t.Run("Shows Help", func(t *testing.T) { 11 | res := dt("charts") 12 | res.AssertSuccess(t) 13 | for _, reStr := range []string{ 14 | `annotate\s+Annotates a Helm chart`, 15 | `carvelize\s+Adds a Carvel bundle to the Helm chart`, 16 | `relocate\s+Relocates a Helm chart`, 17 | } { 18 | res.AssertSuccessMatch(t, fmt.Sprintf(`(?s).*Available Commands:.*\n\s*%s.*`, reStr)) 19 | } 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /cmd/dt/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/login" 6 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/logout" 7 | ) 8 | 9 | var authCmd = &cobra.Command{ 10 | Use: "auth", 11 | Short: "Authentication commands", 12 | SilenceUsage: true, 13 | SilenceErrors: true, 14 | Run: func(cmd *cobra.Command, _ []string) { 15 | _ = cmd.Help() 16 | }, 17 | } 18 | 19 | func init() { 20 | authCmd.AddCommand(login.NewCmd(mainConfig), logout.NewCmd(mainConfig)) 21 | } 22 | -------------------------------------------------------------------------------- /cmd/dt/images_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func (suite *CmdSuite) TestImagesHelp() { 9 | t := suite.T() 10 | t.Run("Shows Help", func(t *testing.T) { 11 | res := dt("images") 12 | res.AssertSuccess(t) 13 | for _, reStr := range []string{ 14 | `lock\s+Creates the lock file`, 15 | `pull\s+Pulls the images from the Images\.lock`, 16 | `push\s+Pushes the images from Images\.lock`, 17 | `verify\s+Verifies the images in an Images\.lock`, 18 | } { 19 | res.AssertSuccessMatch(t, fmt.Sprintf(`(?s).*Available Commands:.*\n\s*%s.*`, reStr)) 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /testdata/scenarios/complete-chart/Chart.yaml.tmpl: -------------------------------------------------------------------------------- 1 | name: {{or .Name "WordPress"}} 2 | version: {{or .Version "1.0.0"}} 3 | annotations: 4 | category: CMS 5 | licenses: Apache-2.0 6 | {{if .AnnotationsKey}}{{.AnnotationsKey}}{{else}}images{{end}}: | 7 | {{- include "images.partial.tmpl" . | indent 6 }} 8 | appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} 9 | {{if .Dependencies }} 10 | {{if gt (len .Dependencies) 0 }} 11 | dependencies: 12 | {{- range .Dependencies}} 13 | - name: {{.Name}} 14 | repository: {{.Repository}} 15 | version: {{.Version}} 16 | {{end -}} 17 | {{end}} 18 | {{end}} 19 | -------------------------------------------------------------------------------- /testdata/scenarios/custom-chart/imagelock.partial.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: v0 2 | kind: ImagesLock 3 | metadata: 4 | generatedAt: "2023-07-13T16:30:33.284125307Z" 5 | generatedBy: Distribution Tooling for Helm 6 | chart: 7 | name: {{.Name}} 8 | version: 1.0.0 9 | appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} 10 | images: 11 | {{- $p := . -}} 12 | {{- range $idx, $elem := .Images}} 13 | - name: {{$elem.Name}} 14 | image: {{$p.ServerURL}}/{{$elem.Image}} 15 | chart: {{$p.Name}} 16 | digests: 17 | {{- range .Digests}} 18 | - digest: {{.Digest}} 19 | arch: {{.Arch}} 20 | {{- end}} 21 | {{- end}} 22 | -------------------------------------------------------------------------------- /testdata/scenarios/complete-chart/imagelock.partial.tmpl: -------------------------------------------------------------------------------- 1 | apiVersion: v0 2 | kind: ImagesLock 3 | metadata: 4 | generatedAt: "2023-07-13T16:30:33.284125307Z" 5 | generatedBy: Distribution Tooling for Helm 6 | chart: 7 | name: {{.Name}} 8 | version: 1.0.0 9 | appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} 10 | images: 11 | {{- $p := . -}} 12 | {{- range $idx, $elem := .Images}} 13 | - name: {{$elem.Name}} 14 | image: {{$p.ServerURL}}/{{$elem.Image}} 15 | chart: {{$p.Name}} 16 | digests: 17 | {{- range .Digests}} 18 | - digest: {{.Digest}} 19 | arch: {{.Arch}} 20 | {{- end}} 21 | {{- end}} 22 | -------------------------------------------------------------------------------- /cmd/dt/chart.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/annotate" 6 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/carvelize" 7 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/relocate" 8 | ) 9 | 10 | var chartCmd = &cobra.Command{ 11 | Use: "charts", 12 | Short: "Helm chart management commands", 13 | SilenceUsage: true, 14 | SilenceErrors: true, 15 | Run: func(cmd *cobra.Command, _ []string) { 16 | _ = cmd.Help() 17 | }, 18 | } 19 | 20 | func init() { 21 | chartCmd.AddCommand(relocate.NewCmd(mainConfig), annotate.NewCmd(mainConfig), carvelize.NewCmd(mainConfig)) 22 | } 23 | -------------------------------------------------------------------------------- /internal/testutil/utils.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func fileSplit(p string) []string { 11 | return strings.Split(filepath.Clean(p), "/") 12 | } 13 | 14 | func fileExists(f string) bool { 15 | if _, err := os.Stat(f); err == nil { 16 | return true 17 | } 18 | return false 19 | } 20 | 21 | func copyFile(srcFile string, destFile string) error { 22 | src, err := os.Open(srcFile) 23 | if err != nil { 24 | return err 25 | } 26 | defer src.Close() 27 | 28 | dest, err := os.Create(destFile) 29 | if err != nil { 30 | return err 31 | } 32 | defer dest.Close() 33 | 34 | _, err = io.Copy(dest, src) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return dest.Sync() 40 | } 41 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | release: 2 | target_commitish: '{{ .Commit }}' 3 | builds: 4 | - id: dt 5 | binary: dt 6 | main: ./cmd/dt 7 | env: 8 | - CGO_ENABLED=0 9 | targets: 10 | - darwin_amd64 11 | - darwin_arm64 12 | - linux_amd64 13 | - linux_arm64 14 | - linux_arm 15 | - windows_amd64 16 | mod_timestamp: "{{ .CommitTimestamp }}" 17 | ldflags: 18 | - >- 19 | -X main.Version={{ .Tag }} 20 | -X main.GitCommit={{ .Commit }} 21 | -X main.BuildDate={{ .Date }} 22 | archives: 23 | - builds: 24 | - dt 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | checksum: 29 | algorithm: sha256 30 | changelog: 31 | sort: asc 32 | filters: 33 | exclude: 34 | - '^docs:' 35 | -------------------------------------------------------------------------------- /cmd/dt/images.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/lock" 6 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/pull" 7 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/push" 8 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/verify" 9 | ) 10 | 11 | var imagesCmd = &cobra.Command{ 12 | Use: "images", 13 | SilenceUsage: true, 14 | SilenceErrors: true, 15 | Short: "Container image management commands", 16 | Run: func(cmd *cobra.Command, _ []string) { 17 | _ = cmd.Help() 18 | }, 19 | } 20 | 21 | func init() { 22 | imagesCmd.AddCommand(lock.NewCmd(mainConfig), verify.NewCmd(mainConfig), pull.NewCmd(mainConfig), push.NewCmd(mainConfig)) 23 | } 24 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/charts/mariadb/Chart.yaml.tmpl: -------------------------------------------------------------------------------- 1 | annotations: 2 | category: Database 3 | images: | 4 | - image: {{.ServerURL}}/bitnami/mysqld-exporter:0.14.0-debian-11-r125 5 | name: mysqld-exporter 6 | - image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r123 7 | name: bitnami-shell 8 | - image: {{.ServerURL}}/bitnami/mariadb:10.11.4-debian-11-r0 9 | name: mariadb 10 | licenses: Apache-2.0 11 | apiVersion: v2 12 | appVersion: 10.11.4 13 | description: MariaDB is an open source, community-developed SQL database server that is widely in use around the world due to its enterprise features, flexibility, and collaboration with leading tech firms. 14 | home: https://bitnami.com 15 | icon: https://bitnami.com/assets/stacks/mariadb/img/mariadb-stack-220x234.png 16 | name: mariadb 17 | version: 12.2.5 18 | -------------------------------------------------------------------------------- /pkg/imagelock/image_test.go: -------------------------------------------------------------------------------- 1 | package imagelock 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestImageList_ToAnnotation(t *testing.T) { 14 | images := ImageList{ 15 | { 16 | Image: "app:latest", 17 | Name: "app1", 18 | }, 19 | { 20 | Image: "blog:v2", 21 | Name: "app2", 22 | }, 23 | } 24 | t.Run("ImageList serializes as annotation", func(t *testing.T) { 25 | expected := "" 26 | for _, img := range images { 27 | expected += fmt.Sprintf("- name: %s\n image: %s\n", img.Name, img.Image) 28 | } 29 | got, err := images.ToAnnotation() 30 | require.NoError(t, err) 31 | assert.Equal(t, tu.MustNormalizeYAML(expected), tu.MustNormalizeYAML(string(got))) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /cmd/dt/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // Version is the tool version 12 | var Version = "0.4.12" 13 | 14 | // BuildDate is the tool build date 15 | var BuildDate = "" 16 | 17 | // Commit is the commit sha of the code used to build the tool 18 | var Commit = "" 19 | 20 | var versionCmd = &cobra.Command{ 21 | Use: "version", 22 | Short: "Prints the version", 23 | Run: func(_ *cobra.Command, _ []string) { 24 | msg := fmt.Sprintf("Distribution Tooling for Helm %s\n", Version) 25 | if BuildDate != "" { 26 | msg += fmt.Sprintf("Built on: %s\n", BuildDate) 27 | } 28 | if Commit != "" { 29 | msg += fmt.Sprintf("Git Commit: %s\n", Commit) 30 | } 31 | fmt.Print(msg) 32 | os.Exit(0) 33 | }, 34 | } 35 | 36 | func init() { 37 | Version = strings.TrimSpace(Version) 38 | BuildDate = strings.TrimSpace(BuildDate) 39 | Commit = strings.TrimSpace(Commit) 40 | } 41 | -------------------------------------------------------------------------------- /testdata/scenarios/custom-chart/Chart.yaml.tmpl: -------------------------------------------------------------------------------- 1 | name: {{or .Name "WordPress"}} 2 | version: {{or .Version "1.0.0"}} 3 | annotations: 4 | category: CMS 5 | licenses: Apache-2.0 6 | {{if .AnnotationsKey}}{{.AnnotationsKey}}{{else}}images{{end}}: | 7 | {{- include "images.partial.tmpl" . | indent 6 }} 8 | appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} 9 | {{if .Dependencies }} 10 | {{if gt (len .Dependencies) 0 }} 11 | dependencies: 12 | {{- range .Dependencies}} 13 | - name: {{.Name}} 14 | repository: {{.Repository}} 15 | version: {{.Version}} 16 | {{end -}} 17 | {{end}} 18 | {{end}} 19 | {{if .Authors }} 20 | {{if gt (len .Authors) 0 }} 21 | maintainers: 22 | {{- range .Authors}} 23 | - name: {{.Name}} 24 | email: {{.Email}} 25 | {{end -}} 26 | {{end}} 27 | {{end}} 28 | {{if .Websites }} 29 | {{if gt (len .Websites) 0 }} 30 | sources: 31 | {{- range .Websites}} 32 | - {{.URL}} 33 | {{end -}} 34 | {{end}} 35 | {{end}} 36 | -------------------------------------------------------------------------------- /pkg/log/section_logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | // ProgressBar defines a ProgressBar widget 4 | type ProgressBar interface { 5 | WithTotal(total int) ProgressBar 6 | UpdateTitle(title string) ProgressBar 7 | Add(increment int) ProgressBar 8 | Start(title ...interface{}) (ProgressBar, error) 9 | Stop() 10 | Successf(fmt string, args ...interface{}) 11 | Errorf(fmt string, args ...interface{}) 12 | Infof(fmt string, args ...interface{}) 13 | Warnf(fmt string, args ...interface{}) 14 | } 15 | 16 | // SectionLogger defines an interface for loggers supporting nested levels of loggin 17 | type SectionLogger interface { 18 | Logger 19 | Successf(format string, args ...interface{}) 20 | PrefixText(string) string 21 | StartSection(title string) SectionLogger 22 | // Nest(title string) SectionLogger 23 | // NestLevel() int 24 | Section(title string, fn func(SectionLogger) error) error 25 | ExecuteStep(title string, fn func() error) error 26 | ProgressBar() ProgressBar 27 | } 28 | -------------------------------------------------------------------------------- /pkg/relocator/annotations_test.go: -------------------------------------------------------------------------------- 1 | package relocator 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 10 | ) 11 | 12 | func TestRelocateAnnotations(t *testing.T) { 13 | chartDir := sb.TempFile() 14 | serverURL := "localhost" 15 | 16 | require.NoError(t, tu.RenderScenario("../../testdata/scenarios/chart1", chartDir, map[string]interface{}{"ServerURL": serverURL})) 17 | 18 | newServerURL := "test.example.com" 19 | expectedAnnotations, err := tu.RenderTemplateFile("../../testdata/scenarios/chart1/images.partial.tmpl", map[string]string{"ServerURL": newServerURL}) 20 | require.NoError(t, err) 21 | 22 | expectedAnnotations = strings.TrimSpace(expectedAnnotations) 23 | 24 | newAnnotations, err := RelocateAnnotations(chartDir, newServerURL) 25 | require.NoError(t, err) 26 | 27 | assert.Equal(t, strings.TrimSpace(expectedAnnotations), strings.TrimSpace(newAnnotations)) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/log/silent/logger.go: -------------------------------------------------------------------------------- 1 | package silent 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 8 | ) 9 | 10 | // Logger defines a logger that does not log anything 11 | type Logger struct { 12 | } 13 | 14 | // NewLogger returns a new Logger that does not log any message 15 | func NewLogger() *Logger { 16 | return &Logger{} 17 | } 18 | 19 | // Infof logs nothing 20 | func (l *Logger) Infof(string, ...interface{}) {} 21 | 22 | // Errorf logs nothing 23 | func (l *Logger) Errorf(string, ...interface{}) {} 24 | 25 | // Debugf logs nothing 26 | func (l *Logger) Debugf(string, ...interface{}) {} 27 | 28 | // Warnf logs nothing 29 | func (l *Logger) Warnf(string, ...interface{}) {} 30 | 31 | // Printf logs nothing 32 | func (l *Logger) Printf(string, ...interface{}) {} 33 | 34 | // SetWriter does nothing 35 | func (l *Logger) SetWriter(io.Writer) {} 36 | 37 | // SetLevel does nothing 38 | func (l *Logger) SetLevel(log.Level) {} 39 | 40 | // Failf returns a LoggedError 41 | func (l *Logger) Failf(format string, args ...interface{}) error { 42 | return &log.LoggedError{Err: fmt.Errorf(format, args...)} 43 | } 44 | -------------------------------------------------------------------------------- /pkg/log/logrus/logger.go: -------------------------------------------------------------------------------- 1 | // Package logrus provides a logger implementation using the logrus library 2 | package logrus 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 10 | ) 11 | 12 | // Logger defines a Logger implemented by logrus 13 | type Logger struct { 14 | *logrus.Logger 15 | } 16 | 17 | // Failf logs a formatted error and returns it back 18 | func (l *Logger) Failf(format string, args ...interface{}) error { 19 | err := fmt.Errorf(format, args...) 20 | l.Errorf("%v", err) 21 | return &log.LoggedError{Err: err} 22 | } 23 | 24 | // SetLevel sets the log level 25 | func (l *Logger) SetLevel(level log.Level) { 26 | l.Logger.SetLevel(logrus.Level(level)) 27 | } 28 | 29 | // SetWriter sets the internal writer used by the log 30 | func (l *Logger) SetWriter(w io.Writer) { 31 | l.SetOutput(w) 32 | } 33 | 34 | // Printf prints a message in the log 35 | func (l *Logger) Printf(format string, args ...interface{}) { 36 | l.Infof(format, args...) 37 | } 38 | 39 | // NewLogger returns a Logger implemented by logrus 40 | func NewLogger() *Logger { 41 | return &Logger{Logger: logrus.New()} 42 | } 43 | -------------------------------------------------------------------------------- /cmd/dt/auth_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-containerregistry/pkg/crane" 7 | "github.com/stretchr/testify/require" 8 | "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 9 | 10 | "helm.sh/helm/v3/pkg/repo/repotest" 11 | ) 12 | 13 | func TestLoginLogout(t *testing.T) { 14 | srv, err := repotest.NewTempServerWithCleanup(t, "") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | defer srv.Stop() 19 | 20 | ociSrv, err := testutil.NewOCIServer(t, srv.Root()) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | go ociSrv.ListenAndServe() 25 | 26 | t.Run("can't get catalog without login", func(t *testing.T) { 27 | _, err := crane.Catalog(ociSrv.RegistryURL) 28 | require.ErrorContains(t, err, "UNAUTHORIZED") 29 | }) 30 | 31 | t.Run("can get catalog after login", func(t *testing.T) { 32 | dt("auth", "login", ociSrv.RegistryURL, "-u", "username", "-p", "password").AssertSuccessMatch(t, "logged in via") 33 | _, err := crane.Catalog(ociSrv.RegistryURL) 34 | require.NoError(t, err) 35 | 36 | dt("auth", "logout", ociSrv.RegistryURL).AssertSuccessMatch(t, "logged out via") 37 | _, err = crane.Catalog(ociSrv.RegistryURL) 38 | require.ErrorContains(t, err, "UNAUTHORIZED") 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/relocator/annotations.go: -------------------------------------------------------------------------------- 1 | package relocator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 7 | ) 8 | 9 | // RelocateAnnotations rewrites the image urls in the chart annotations using the provided prefix 10 | func RelocateAnnotations(chartDir string, prefix string, opts ...chartutils.Option) (string, error) { 11 | c, err := chartutils.LoadChart(chartDir, opts...) 12 | if err != nil { 13 | return "", fmt.Errorf("failed to relocate annotations: %w", err) 14 | } 15 | res, err := relocateAnnotations(c, prefix) 16 | if err != nil { 17 | return "", fmt.Errorf("failed to relocate annotations: %w", err) 18 | } 19 | return string(res.Data), nil 20 | } 21 | 22 | func relocateAnnotations(c *chartutils.Chart, prefix string) (*RelocationResult, error) { 23 | images, err := c.GetAnnotatedImages() 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to read images from annotations: %v", err) 26 | } 27 | count, err := relocateImages(images, prefix) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to relocate annotations: %v", err) 30 | } 31 | 32 | data, err := images.ToAnnotation() 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to relocate annotations: %v", err) 35 | } 36 | 37 | result := &RelocationResult{Data: data, Count: count} 38 | return result, nil 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/prepare-release.yaml: -------------------------------------------------------------------------------- 1 | name: Prepare release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | tag: 6 | description: 'Release tag (i.e. v1.2.3)' 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | Prepare: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Config Git 20 | run: | 21 | git config user.name "$GITHUB_ACTOR" 22 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 23 | 24 | - name: Fetch Version 25 | run: echo PLUGIN_VERSION=$(echo "${{ inputs.tag }}" | tr -d 'v') >> "$GITHUB_ENV" 26 | 27 | - name: Update Version 28 | run: | 29 | sed -i "s/version: \".*\"/version: \"$PLUGIN_VERSION\"/" plugin.yaml 30 | sed -i "s/var Version = \".*\"/var Version = \"$PLUGIN_VERSION\"/" cmd/dt/version.go 31 | git checkout -B release/$PLUGIN_VERSION 32 | git add plugin.yaml cmd/dt/version.go 33 | git commit -m 'Prepare release ${{ inputs.tag }}' 34 | git push origin release/$PLUGIN_VERSION 35 | 36 | - name: Create PR 37 | run: gh pr create --fill --base main --repo $GITHUB_REPOSITORY 38 | env: 39 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" 3 | linters: 4 | enable: 5 | - bodyclose 6 | - dogsled 7 | - gocyclo 8 | - gosec 9 | - govet 10 | - ineffassign 11 | - lll 12 | - misspell 13 | - nakedret 14 | - revive 15 | - staticcheck 16 | - unconvert 17 | - unused 18 | disable: 19 | - errcheck 20 | exclusions: 21 | presets: 22 | - comments 23 | - common-false-positives 24 | - legacy 25 | - std-error-handling 26 | rules: 27 | - path: '(.+)\.go$' 28 | text: "(Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less)" 29 | - path: '(.+)\.go$' 30 | text: "Potential file inclusion via variable" 31 | - path: '(.+)\.go$' 32 | text: "G306: Expect WriteFile permissions to be 0600 or less" 33 | - path: '(.+)\.go$' 34 | text: "avoid meaningless package names" 35 | settings: 36 | gocyclo: 37 | min-complexity: 18 38 | govet: 39 | enable: 40 | - shadow 41 | lll: 42 | line-length: 200 43 | formatters: 44 | enable: 45 | - gofmt 46 | - goimports 47 | run: 48 | timeout: 5m 49 | 50 | issues: 51 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 52 | max-issues-per-linter: 0 53 | 54 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 55 | max-same-issues: 0 56 | -------------------------------------------------------------------------------- /pkg/log/silent/section_logger.go: -------------------------------------------------------------------------------- 1 | // Package silent implements a silent logger 2 | package silent 3 | 4 | import ( 5 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 6 | ) 7 | 8 | // SectionLogger is a SectionLogger that does not output anything 9 | type SectionLogger struct { 10 | *Logger 11 | } 12 | 13 | // NewSectionLogger creates a new SilentSectionLogger 14 | func NewSectionLogger() *SectionLogger { 15 | return &SectionLogger{&Logger{}} 16 | } 17 | 18 | // ExecuteStep executes a function while showing an indeterminate progress animation 19 | func (l *SectionLogger) ExecuteStep(_ string, fn func() error) error { 20 | return fn() 21 | } 22 | 23 | // PrefixText returns the indented version of the provided text 24 | func (l *SectionLogger) PrefixText(txt string) string { 25 | return txt 26 | } 27 | 28 | // StartSection starts a new log section 29 | func (l *SectionLogger) StartSection(string) log.SectionLogger { 30 | return l 31 | } 32 | 33 | // ProgressBar returns a new silent progress bar 34 | func (l *SectionLogger) ProgressBar() log.ProgressBar { 35 | return NewProgressBar() 36 | } 37 | 38 | // Successf logs a new success message (more efusive than Infof) 39 | func (l *SectionLogger) Successf(string, ...interface{}) { 40 | } 41 | 42 | // Section executes the provided function inside a new section 43 | func (l *SectionLogger) Section(_ string, fn func(log.SectionLogger) error) error { 44 | return fn(l) 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | types: 10 | - assigned 11 | - opened 12 | - synchronize 13 | - reopened 14 | 15 | jobs: 16 | Validate: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Code 20 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set Helm 25 | uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 26 | with: 27 | version: v3.12.1 28 | 29 | - name: Set Golang 30 | uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 31 | with: 32 | go-version: '^1.25' 33 | 34 | - name: Set Golangci-lint 35 | run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.4.0 36 | 37 | - name: Set Shellcheck 38 | run: sudo apt-get -qq update && sudo apt-get install -y shellcheck && shellcheck install-binary.sh 39 | 40 | - name: Build 41 | run: make build 42 | 43 | - name: Test 44 | run: make test 45 | 46 | - name: Install 47 | run: make install 48 | 49 | - name: Check Binary 50 | run: ./bin/dt 51 | 52 | - name: Check Helm Plugin 53 | run: helm dt 54 | -------------------------------------------------------------------------------- /pkg/log/logrus/section_logger.go: -------------------------------------------------------------------------------- 1 | package logrus 2 | 3 | import "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 4 | 5 | // SectionLogger defines a SectionLogger implemented by logrus 6 | type SectionLogger struct { 7 | *Logger 8 | } 9 | 10 | // ExecuteStep executes a function while showing an indeterminate progress animation 11 | func (l *SectionLogger) ExecuteStep(title string, fn func() error) error { 12 | l.Info(title) 13 | return fn() 14 | } 15 | 16 | // PrefixText returns the indented version of the provided text 17 | func (l *SectionLogger) PrefixText(txt string) string { 18 | return txt 19 | } 20 | 21 | // StartSection starts a new log section 22 | func (l *SectionLogger) StartSection(string) log.SectionLogger { 23 | return l 24 | } 25 | 26 | // ProgressBar returns a new silent progress bar 27 | func (l *SectionLogger) ProgressBar() log.ProgressBar { 28 | return log.NewLoggedProgressBar(l.Logger) 29 | } 30 | 31 | // Successf logs a new success message (more efusive than Infof) 32 | func (l *SectionLogger) Successf(format string, args ...interface{}) { 33 | l.Infof(format, args...) 34 | } 35 | 36 | // Section executes the provided function inside a new section 37 | func (l *SectionLogger) Section(title string, fn func(log.SectionLogger) error) error { 38 | l.Info(title) 39 | return fn(l) 40 | } 41 | 42 | // NewSectionLogger returns a new SectionLogger implemented by logrus 43 | func NewSectionLogger() *SectionLogger { 44 | return &SectionLogger{NewLogger()} 45 | } 46 | -------------------------------------------------------------------------------- /pkg/log/silent/progress.go: -------------------------------------------------------------------------------- 1 | package silent 2 | 3 | import "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 4 | 5 | // ProgressBar defines a widget that supports the ProgressBar interface and does nothing 6 | type ProgressBar struct { 7 | } 8 | 9 | // NewProgressBar returns a new ProgressBar that does not produce any output 10 | func NewProgressBar() *ProgressBar { 11 | return &ProgressBar{} 12 | } 13 | 14 | // Stop stops the progress bar 15 | func (p *ProgressBar) Stop() { 16 | } 17 | 18 | // Start initiates the progress bar 19 | func (p *ProgressBar) Start(...interface{}) (log.ProgressBar, error) { 20 | return p, nil 21 | } 22 | 23 | // WithTotal sets the progress bar total steps 24 | func (p *ProgressBar) WithTotal(int) log.ProgressBar { 25 | return p 26 | } 27 | 28 | // Errorf shows an error message 29 | func (p *ProgressBar) Errorf(string, ...interface{}) { 30 | 31 | } 32 | 33 | // Infof shows an info message 34 | func (p *ProgressBar) Infof(string, ...interface{}) { 35 | } 36 | 37 | // Successf displays a success message 38 | func (p *ProgressBar) Successf(string, ...interface{}) { 39 | } 40 | 41 | // Warnf displays a warning message 42 | func (p *ProgressBar) Warnf(string, ...interface{}) { 43 | } 44 | 45 | // UpdateTitle updates the progress bar title 46 | func (p *ProgressBar) UpdateTitle(string) log.ProgressBar { 47 | return p 48 | } 49 | 50 | // Add increments the progress bar the specified amount 51 | func (p *ProgressBar) Add(int) log.ProgressBar { 52 | return p 53 | } 54 | -------------------------------------------------------------------------------- /internal/widgets/spinner.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/pterm/pterm" 5 | "github.com/pterm/pterm/putils" 6 | ) 7 | 8 | var ( 9 | // DefaultSpinner defines the default spinner widget 10 | DefaultSpinner Spinner 11 | ) 12 | 13 | func prefixSequence(prefix string, sequence ...string) []string { 14 | newSequence := make([]string, len(sequence)) 15 | for i, str := range sequence { 16 | newSequence[i] = prefix + str 17 | } 18 | return newSequence 19 | } 20 | 21 | // Spinner defines a widget that shows a indeterminate progress animation 22 | type Spinner struct { 23 | *pterm.SpinnerPrinter 24 | } 25 | 26 | // WithPrefix returns a new Spinner the with the specified prefix 27 | func (s *Spinner) WithPrefix(prefix string) *Spinner { 28 | return &Spinner{s.WithSequence(prefixSequence(prefix, s.Sequence...)...)} 29 | } 30 | 31 | func init() { 32 | DefaultSpinner = Spinner{pterm.DefaultSpinner.WithSequence(prefixSequence(" ", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")...)} 33 | } 34 | 35 | // ExecuteWithSpinner runs the provided function while executing spinner 36 | func ExecuteWithSpinner(spinner *Spinner, message string, fn func() error) error { 37 | return putils.RunWithSpinner(spinner.WithRemoveWhenDone(true).WithText(message), func(_ *pterm.SpinnerPrinter) error { 38 | return fn() 39 | }) 40 | } 41 | 42 | // ExecuteWithDefaultSpinner runs the provided function while executing the default spinner 43 | func ExecuteWithDefaultSpinner(message string, fn func() error) error { 44 | return ExecuteWithSpinner(&DefaultSpinner, message, fn) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/utils/copy.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // CopyFile file copies src to dest, preserving permissions 12 | func CopyFile(src, dest string) error { 13 | info, err := os.Stat(src) 14 | if err != nil { 15 | return fmt.Errorf("failed to stat source file: %w", err) 16 | } 17 | return copyFile(src, dest, info.Mode()) 18 | } 19 | 20 | // CopyDir copies the directory src to dest, including its contents 21 | func CopyDir(src, dest string) error { 22 | if err := os.MkdirAll(dest, 0755); err != nil { 23 | return err 24 | } 25 | 26 | return filepath.WalkDir(src, func(path string, entry fs.DirEntry, err error) error { 27 | if err != nil { 28 | return err 29 | } 30 | relPath, err := filepath.Rel(src, path) 31 | if err != nil { 32 | return fmt.Errorf("failed to get relative path: %w", err) 33 | } 34 | destPath := filepath.Join(dest, relPath) 35 | 36 | info, err := entry.Info() 37 | if err != nil { 38 | return fmt.Errorf("failed to get source file info: %w", err) 39 | } 40 | 41 | if entry.IsDir() { 42 | return os.MkdirAll(destPath, info.Mode()) 43 | } 44 | 45 | return copyFile(path, destPath, info.Mode()) 46 | }) 47 | } 48 | 49 | func copyFile(src, dest string, info fs.FileMode) error { 50 | srcFile, err := os.Open(src) 51 | if err != nil { 52 | return err 53 | } 54 | defer srcFile.Close() 55 | 56 | destFile, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, info) 57 | 58 | if err != nil { 59 | return err 60 | } 61 | defer destFile.Close() 62 | 63 | _, err = io.Copy(destFile, srcFile) 64 | if err != nil { 65 | return err 66 | } 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /cmd/dt/annotate/annotate.go: -------------------------------------------------------------------------------- 1 | // Package annotate implements the dt charts annotate command 2 | package annotate 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 11 | ) 12 | 13 | // NewCmd builds a new annotate command 14 | func NewCmd(cfg *config.Config) *cobra.Command { 15 | return &cobra.Command{ 16 | Use: "annotate CHART_PATH", 17 | Short: "Annotates a Helm chart (Experimental)", 18 | Long: `Experimental. Tries to annotate a Helm chart by guesing the container images from the information at values.yaml. 19 | 20 | Use it cautiously. Very often the complete list of images cannot be guessed from information in values.yaml`, 21 | Example: ` # Annotate an example Helm chart 22 | $ dt charts annotate examples/mongodb`, 23 | SilenceUsage: true, 24 | SilenceErrors: true, 25 | Args: cobra.ExactArgs(1), 26 | RunE: func(_ *cobra.Command, args []string) error { 27 | chartPath := args[0] 28 | l := cfg.Logger() 29 | 30 | err := l.ExecuteStep(fmt.Sprintf("Annotating Helm chart %q", chartPath), func() error { 31 | return chartutils.AnnotateChart(chartPath, 32 | chartutils.WithAnnotationsKey(cfg.AnnotationsKey), 33 | chartutils.WithLog(l), 34 | ) 35 | 36 | }) 37 | 38 | if err != nil { 39 | if errors.Is(err, chartutils.ErrNoImagesToAnnotate) { 40 | l.Warnf("No container images found to be annotated") 41 | return nil 42 | } 43 | return l.Failf("failed to annotate Helm chart %q: %v", chartPath, err) 44 | } 45 | 46 | l.Successf("Helm chart annotated successfully") 47 | 48 | return nil 49 | }, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_run: 4 | workflows: 5 | - CI 6 | types: 7 | - completed 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | Release: 16 | runs-on: ubuntu-latest 17 | if: ${{ github.event.workflow_run.conclusion == 'success' && contains(github.event.workflow_run.head_commit.message, 'Prepare release v') }} 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Fetch Version 25 | run: | 26 | PLUGIN_VERSION=v$(cat plugin.yaml | grep "version" | cut -d '"' -f 2) 27 | LATEST_VERSION=$(git describe --tags --abbrev=0) 28 | echo PLUGIN_VERSION=$PLUGIN_VERSION >> "$GITHUB_ENV" 29 | echo LATEST_VERSION=$LATEST_VERSION >> "$GITHUB_ENV" 30 | 31 | - name: Check Version 32 | if: ${{ env.PLUGIN_VERSION == env.LATEST_VERSION }} 33 | run: echo "Plugin version already released. Please make sure you have prepared the release first." && exit 1 34 | 35 | - name: Set Golang 36 | uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 37 | with: 38 | go-version: '^1.25' 39 | 40 | - name: Build 41 | run: make build 42 | 43 | - name: Create tag 44 | run: git tag $PLUGIN_VERSION 45 | 46 | - name: Run GoReleaser 47 | uses: goreleaser/goreleaser-action@336e29918d653399e599bfca99fadc1d7ffbc9f7 # v4.3.0 48 | with: 49 | distribution: goreleaser 50 | version: latest 51 | args: release --clean 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /pkg/relocator/imagelock.go: -------------------------------------------------------------------------------- 1 | package relocator 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 10 | ) 11 | 12 | func relocateImages(images imagelock.ImageList, prefix string) (count int, err error) { 13 | var allErrors error 14 | for _, img := range images { 15 | norm, err := utils.RelocateImageURL(img.Image, prefix, true) 16 | if err != nil { 17 | allErrors = errors.Join(allErrors, err) 18 | continue 19 | } 20 | img.Image = norm 21 | count++ 22 | } 23 | return count, allErrors 24 | } 25 | 26 | // RelocateLock rewrites the images urls in the provided lock using prefix 27 | func RelocateLock(lock *imagelock.ImagesLock, prefix string) (*RelocationResult, error) { 28 | count, err := relocateImages(lock.Images, prefix) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to relocate Images.lock file: %v", err) 31 | } 32 | buff := &bytes.Buffer{} 33 | if err := lock.ToYAML(buff); err != nil { 34 | return nil, fmt.Errorf("failed to write Images.lock file: %v", err) 35 | } 36 | return &RelocationResult{Data: buff.Bytes(), Count: count}, nil 37 | } 38 | 39 | // RelocateLockFile relocates images urls in the provided Images.lock using prefix 40 | func RelocateLockFile(file string, prefix string) error { 41 | lock, err := imagelock.FromYAMLFile(file) 42 | if err != nil { 43 | return fmt.Errorf("failed to load Images.lock: %v", err) 44 | } 45 | result, err := RelocateLock(lock, prefix) 46 | if err != nil { 47 | return err 48 | } 49 | if result.Count == 0 { 50 | return nil 51 | } 52 | if err := utils.SafeWriteFile(file, result.Data, 0600); err != nil { 53 | return fmt.Errorf("failed to overwrite Images.lock file: %v", err) 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/log/pterm/printers.go: -------------------------------------------------------------------------------- 1 | package pterm 2 | 3 | import "github.com/pterm/pterm" 4 | 5 | var ( 6 | // Fold defines a printer that prefixes text by a 'fold' symbol 7 | Fold = pterm.Info.WithMessageStyle(&pterm.ThemeDefault.InfoMessageStyle).WithPrefix(pterm.Prefix{ 8 | Style: pterm.NewStyle(pterm.FgBlue), 9 | Text: "\u00BB", // "»" 10 | }) 11 | // Plain defines a printer with empty prefix 12 | Plain = pterm.Info.WithMessageStyle(&pterm.ThemeDefault.InfoMessageStyle).WithPrefix(pterm.Prefix{ 13 | Style: pterm.NewStyle(pterm.FgBlue), 14 | Text: " ", 15 | }) 16 | // Error defines a printer for errors 17 | Error = pterm.Error.WithMessageStyle(&pterm.ThemeDefault.ErrorMessageStyle).WithPrefix(pterm.Prefix{ 18 | Style: pterm.NewStyle(pterm.FgRed), 19 | Text: "\u2718", // "✘" 20 | }) 21 | // Warning defines a printer for warnings 22 | Warning = pterm.Warning.WithMessageStyle(&pterm.ThemeDefault.WarningMessageStyle).WithPrefix(pterm.Prefix{ 23 | Style: pterm.NewStyle(pterm.FgYellow), 24 | Text: "\u26A0\uFE0F", 25 | }) 26 | // Debug defines a printer for debug messages 27 | Debug = pterm.Debug.WithMessageStyle(&pterm.ThemeDefault.DebugMessageStyle).WithDebugger(false).WithPrefix(pterm.Prefix{ 28 | Style: pterm.NewStyle(pterm.FgGray), 29 | Text: "\U0001F50D", 30 | }) 31 | // Info defines a printer for info messages 32 | Info = pterm.Success.WithMessageStyle(&pterm.ThemeDefault.SuccessMessageStyle).WithPrefix(pterm.Prefix{ 33 | Style: pterm.NewStyle(pterm.FgLightGreen), 34 | Text: "\u2714", // "✔" 35 | }) 36 | // Success defines a printer for success messages 37 | Success = pterm.Success.WithMessageStyle(&pterm.ThemeDefault.SuccessMessageStyle).WithPrefix(pterm.Prefix{ 38 | Style: pterm.NewStyle(pterm.FgLightGreen), 39 | // This is a rocket 40 | // Text: "\U0001F680", 41 | Text: "\U0001F389", 42 | }) 43 | ) 44 | -------------------------------------------------------------------------------- /pkg/relocator/values.go: -------------------------------------------------------------------------------- 1 | package relocator 2 | 3 | import ( 4 | "fmt" 5 | 6 | cu "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 7 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 8 | 9 | "helm.sh/helm/v3/pkg/chartutil" 10 | ) 11 | 12 | func relocateValuesData(valuesFile string, valuesData []byte, prefix string) (*RelocationResult, error) { 13 | valuesMap, err := chartutil.ReadValues(valuesData) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to parse Helm chart values: %v", err) 16 | } 17 | imageElems, err := cu.FindImageElementsInValuesMap(valuesMap) 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to find Helm chart image elements from values.yaml: %v", err) 20 | } 21 | if len(imageElems) == 0 { 22 | return &RelocationResult{Data: valuesData, Count: 0}, nil 23 | } 24 | 25 | data := make(map[string]string, 0) 26 | for _, e := range imageElems { 27 | if err = e.Relocate(prefix); err != nil { 28 | return nil, fmt.Errorf("unexpected error relocating: %v", err) 29 | } 30 | for k, v := range e.YamlReplaceMap() { 31 | data[k] = v 32 | } 33 | } 34 | relocatedData, err := utils.YamlSet(valuesData, data) 35 | if err != nil { 36 | return nil, fmt.Errorf("unexpected error relocating: %v", err) 37 | } 38 | return &RelocationResult{Name: valuesFile, Data: relocatedData, Count: len(imageElems)}, nil 39 | } 40 | 41 | func relocateValues(c *cu.Chart, prefix string) ([]*RelocationResult, error) { 42 | result := make([]*RelocationResult, 0, len(c.ValuesFiles())) 43 | for _, values := range c.ValuesFiles() { 44 | if values == nil { 45 | result = append(result, &RelocationResult{}) 46 | continue 47 | } 48 | res, err := relocateValuesData(values.Name, values.Data, prefix) 49 | if err != nil { 50 | return nil, err 51 | } 52 | result = append(result, res) 53 | } 54 | return result, nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/log/progress.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | // LoggedProgressBar defines a widget that supports the ProgressBar interface but just logs messages 4 | type LoggedProgressBar struct { 5 | Logger 6 | totalSteps int 7 | currentSteps int 8 | } 9 | 10 | // NewLoggedProgressBar returns a progress bar that just log messages 11 | func NewLoggedProgressBar(l Logger) *LoggedProgressBar { 12 | return &LoggedProgressBar{Logger: l} 13 | } 14 | 15 | // Stop stops the progress bar 16 | func (p *LoggedProgressBar) Stop() { 17 | } 18 | 19 | // Start initiates the progress bar 20 | func (p *LoggedProgressBar) Start(...interface{}) (ProgressBar, error) { 21 | return p, nil 22 | } 23 | 24 | // WithTotal sets the progress bar total steps 25 | func (p *LoggedProgressBar) WithTotal(steps int) ProgressBar { 26 | p.totalSteps = steps 27 | return p 28 | } 29 | 30 | // Error shows an error message 31 | func (p *LoggedProgressBar) Error(fmt string, args ...interface{}) { 32 | p.Errorf(fmt, args...) 33 | } 34 | 35 | // Info shows an info message 36 | func (p *LoggedProgressBar) Info(fmt string, args ...interface{}) { 37 | p.Infof(fmt, args...) 38 | } 39 | 40 | // Successf displays a success message 41 | func (p *LoggedProgressBar) Successf(fmt string, args ...interface{}) { 42 | p.Infof(fmt, args...) 43 | } 44 | 45 | // Warning displays a warning message 46 | func (p *LoggedProgressBar) Warning(fmt string, args ...interface{}) { 47 | p.Warnf(fmt, args...) 48 | } 49 | 50 | // UpdateTitle updates the progress bar title 51 | func (p *LoggedProgressBar) UpdateTitle(str string) ProgressBar { 52 | p.Infof("[ %3d/%3d ] %s", p.currentSteps, p.totalSteps, str) 53 | return p 54 | } 55 | 56 | // Add increments the progress bar the specified amount 57 | func (p *LoggedProgressBar) Add(steps int) ProgressBar { 58 | newSteps := p.currentSteps + steps 59 | if newSteps > p.totalSteps { 60 | newSteps = p.totalSteps 61 | } 62 | p.currentSteps = newSteps 63 | return p 64 | } 65 | -------------------------------------------------------------------------------- /cmd/dt/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/info" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/unwrap" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/wrap" 13 | ) 14 | 15 | var rootCmd = newRootCmd() 16 | 17 | var mainConfig = config.NewConfig() 18 | 19 | func newRootCmd() *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: filepath.Base(os.Args[0]), 22 | Run: func(cmd *cobra.Command, _ []string) { 23 | _ = cmd.Help() 24 | }, 25 | } 26 | cmd.PersistentFlags().BoolVar(&mainConfig.Insecure, "insecure", mainConfig.Insecure, "skip TLS verification") 27 | cmd.PersistentFlags().BoolVar(&mainConfig.UsePlainHTTP, "use-plain-http", mainConfig.UsePlainHTTP, "use plain HTTP when pulling and pushing charts") 28 | cmd.PersistentFlags().StringVar(&mainConfig.AnnotationsKey, "annotations-key", mainConfig.AnnotationsKey, "annotation key used to define the list of included images") 29 | 30 | cmd.PersistentFlags().StringVar(&mainConfig.LogLevel, "log-level", mainConfig.LogLevel, "set log level: (trace, debug, info, warn, error, fatal, panic)") 31 | cmd.PersistentFlags().BoolVar(&mainConfig.UsePlainLog, "plain", mainConfig.UsePlainLog, "suppress the progress bar and symbols in messages and display only plain log messages") 32 | cmd.PersistentFlags().BoolVar(&config.KeepArtifacts, "keep-artifacts", config.KeepArtifacts, "keep temporary artifacts created during the tool execution") 33 | 34 | // Do not show completion command 35 | cmd.CompletionOptions.DisableDefaultCmd = true 36 | 37 | cmd.AddCommand(authCmd) 38 | cmd.AddCommand(chartCmd) 39 | cmd.AddCommand(imagesCmd) 40 | cmd.AddCommand(versionCmd) 41 | cmd.AddCommand(wrap.NewCmd(mainConfig), unwrap.NewCmd(mainConfig), info.NewCmd(mainConfig)) 42 | 43 | return cmd 44 | } 45 | -------------------------------------------------------------------------------- /cmd/dt/logout/logout.go: -------------------------------------------------------------------------------- 1 | // Package logout implements the command to logout from OCI registries 2 | package logout 3 | 4 | import ( 5 | "os" 6 | 7 | dockercfg "github.com/docker/cli/cli/config" 8 | "github.com/google/go-containerregistry/pkg/authn" 9 | "github.com/google/go-containerregistry/pkg/name" 10 | "github.com/spf13/cobra" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 13 | ) 14 | 15 | // NewCmd returns a new dt logout command 16 | func NewCmd(cfg *config.Config) *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: "logout REGISTRY", 19 | Short: "Logout from an OCI registry (Experimental)", 20 | Long: "Experimental. Logout from an OCI registry using the Docker configuration file", 21 | Args: cobra.ExactArgs(1), 22 | Example: ` # Log out from index.docker.io 23 | $ dt auth logout index.docker.io`, 24 | SilenceUsage: true, 25 | SilenceErrors: true, 26 | RunE: func(_ *cobra.Command, args []string) error { 27 | l := cfg.Logger() 28 | 29 | reg, err := name.NewRegistry(args[0]) 30 | if err != nil { 31 | return l.Failf("failed to load registry %s: %v", args[0], err) 32 | } 33 | serverAddress := reg.Name() 34 | 35 | return logout(serverAddress, l) 36 | }, 37 | } 38 | 39 | return cmd 40 | } 41 | 42 | // from https://github.com/google/go-containerregistry/blob/main/cmd/crane/cmd/auth.go 43 | func logout(serverAddress string, l log.SectionLogger) error { 44 | l.Infof("logout from %s", serverAddress) 45 | cf, err := dockercfg.Load(os.Getenv("DOCKER_CONFIG")) 46 | if err != nil { 47 | return l.Failf("failed to load configuration: %v", err) 48 | } 49 | creds := cf.GetCredentialsStore(serverAddress) 50 | if serverAddress == name.DefaultRegistry { 51 | serverAddress = authn.DefaultAuthKey 52 | } 53 | if err := creds.Erase(serverAddress); err != nil { 54 | return l.Failf("failed to store credentials: %v", err) 55 | } 56 | 57 | if err := cf.Save(); err != nil { 58 | return l.Failf("failed to save authorization information: %v", err) 59 | } 60 | l.Successf("logged out via %s", cf.Filename) 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /cmd/dt/relocate/relocate.go: -------------------------------------------------------------------------------- 1 | // Package relocate implements the dt relocate command 2 | package relocate 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/relocator" 10 | ) 11 | 12 | // NewCmd builds a new relocate command 13 | func NewCmd(cfg *config.Config) *cobra.Command { 14 | valuesFiles := []string{"values.yaml"} 15 | skipImageRelocation := false 16 | cmd := &cobra.Command{ 17 | Use: "relocate CHART_PATH OCI_URI", 18 | Short: "Relocates a Helm chart", 19 | Long: "Relocates a Helm chart into a new OCI registry. This command will replace the existing registry references with the new registry both in the Images.lock and values.yaml files", 20 | Example: ` # Relocate a chart from DockerHub into demo Harbor 21 | $ dt charts relocate examples/mariadb oci://demo.goharbor.io/test_repo`, 22 | Args: cobra.ExactArgs(2), 23 | SilenceUsage: true, 24 | SilenceErrors: true, 25 | RunE: func(_ *cobra.Command, args []string) error { 26 | chartPath, repository := args[0], args[1] 27 | if repository == "" { 28 | return fmt.Errorf("repository cannot be empty") 29 | } 30 | l := cfg.Logger() 31 | 32 | if err := l.ExecuteStep(fmt.Sprintf("Relocating %q with prefix %q", chartPath, repository), func() error { 33 | return relocator.RelocateChartDir( 34 | chartPath, 35 | repository, 36 | relocator.WithLog(l), relocator.Recursive, 37 | relocator.WithAnnotationsKey(cfg.AnnotationsKey), 38 | relocator.WithValuesFiles(valuesFiles...), 39 | relocator.WithSkipImageRelocation(skipImageRelocation), 40 | ) 41 | }); err != nil { 42 | return l.Failf("failed to relocate Helm chart %q: %w", chartPath, err) 43 | } 44 | 45 | l.Successf("Helm chart relocated successfully") 46 | return nil 47 | }, 48 | } 49 | 50 | cmd.PersistentFlags().StringSliceVar(&valuesFiles, "values", valuesFiles, "values files to relocate images (can specify multiple)") 51 | cmd.PersistentFlags().BoolVar(&skipImageRelocation, "skip-relocation", skipImageRelocation, "skip relocating image references in the different files") 52 | 53 | return cmd 54 | } 55 | -------------------------------------------------------------------------------- /pkg/log/pterm/logger.go: -------------------------------------------------------------------------------- 1 | // Package pterm provides a logger implementation using the pterm library 2 | package pterm 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/pterm/pterm" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 11 | ) 12 | 13 | // NewLogger returns a new Logger implemented by pterm 14 | func NewLogger() *Logger { 15 | return &Logger{writer: os.Stdout, level: log.InfoLevel} 16 | } 17 | 18 | // Logger defines a logger implemented using pterm 19 | type Logger struct { 20 | writer io.Writer 21 | level log.Level 22 | prefix string 23 | } 24 | 25 | func (l *Logger) printMessage(messageLevel log.Level, printer *pterm.PrefixPrinter, format string, args ...interface{}) { 26 | if messageLevel > l.level { 27 | return 28 | } 29 | pterm.Fprintln(l.writer, l.prefix+printer.Sprint(fmt.Sprintf(format, args...))) 30 | } 31 | 32 | // SetWriter sets the internal writer used by the log 33 | func (l *Logger) SetWriter(w io.Writer) { 34 | l.writer = w 35 | } 36 | 37 | // SetLevel sets the log level 38 | func (l *Logger) SetLevel(level log.Level) { 39 | l.level = level 40 | } 41 | 42 | // Failf logs a formatted error and returns it back 43 | func (l *Logger) Failf(format string, args ...interface{}) error { 44 | err := fmt.Errorf(format, args...) 45 | l.Errorf("%v", err) 46 | return &log.LoggedError{Err: err} 47 | } 48 | 49 | // Printf prints a message in the log 50 | func (l *Logger) Printf(format string, args ...interface{}) { 51 | l.printMessage(log.AlwaysLevel, Plain, format, args...) 52 | } 53 | 54 | // Errorf logs an error message 55 | func (l *Logger) Errorf(format string, args ...interface{}) { 56 | l.printMessage(log.ErrorLevel, Error, format, args...) 57 | } 58 | 59 | // Infof logs an information message 60 | func (l *Logger) Infof(format string, args ...interface{}) { 61 | l.printMessage(log.InfoLevel, Info, format, args...) 62 | } 63 | 64 | // Debugf logs a debug message 65 | func (l *Logger) Debugf(format string, args ...interface{}) { 66 | l.printMessage(log.DebugLevel, Debug, format, args...) 67 | } 68 | 69 | // Warnf logs a warning message 70 | func (l *Logger) Warnf(format string, args ...interface{}) { 71 | l.printMessage(log.WarnLevel, Warning, format, args...) 72 | } 73 | -------------------------------------------------------------------------------- /testdata/scenarios/chart1/lock_images.partial.tmpl: -------------------------------------------------------------------------------- 1 | - name: wordpress 2 | image: {{.ServerURL}}/bitnami/wordpress:6.2.2-debian-11-r11 3 | chart: wordpress 4 | digests: 5 | - digest: sha256:a410341508b8823774448bc457730e38d2f047497ffddf090553845493c62e0f 6 | arch: linux/amd64 7 | - digest: sha256:1e5991a54bc98871e61dd7f94697f86b5dc4e2b2560d5590ff292038a6434ba7 8 | arch: linux/arm64 9 | - name: bitnami-shell 10 | image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r124 11 | chart: wordpress 12 | digests: 13 | - digest: sha256:9d9195f30a8c8a82db434acd2e9c7b02f366be17a419245a7856a61117be063f 14 | arch: linux/amd64 15 | - digest: sha256:296dc1939f70667553ac6d3787b5b69561a5590e2719b841e2b26d3b65ba6515 16 | arch: linux/arm64 17 | - name: apache-exporter 18 | image: {{.ServerURL}}/bitnami/apache-exporter:0.13.4-debian-11-r2 19 | chart: wordpress 20 | digests: 21 | - digest: sha256:83acfe5b679dfc843692fc3d122eb7c18f4733855e0c03ba22fa301c2dbf8c05 22 | arch: linux/amd64 23 | - digest: sha256:50ede0624e286591351daa96b86b3e3c8826d699f931a9f854ecbc186ae6ab1c 24 | arch: linux/arm64 25 | - name: mysqld-exporter 26 | image: {{.ServerURL}}/bitnami/mysqld-exporter:0.14.0-debian-11-r125 27 | chart: mariadb 28 | digests: 29 | - digest: sha256:3ae642840c29e2541c63871e8d9e0f8f7a8bc5c45eb0e20afa9d134242a72d12 30 | arch: linux/amd64 31 | - digest: sha256:82f5ebe3529a6cb3ec6a07daf819c1ee881d472fef297bb1f7c4b5d1d0634fea 32 | arch: linux/arm64 33 | - name: bitnami-shell 34 | image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r123 35 | chart: mariadb 36 | digests: 37 | - digest: sha256:cc2370abc4a5d86dcaab4cb6e6e6a58f99fcc6f95b02752cabf8ba193f78d78e 38 | arch: linux/amd64 39 | - digest: sha256:5b7bd35e7935988160f3031766a51665bb709767d24f1e11ca44fc671446f486 40 | arch: linux/arm64 41 | - name: mariadb 42 | image: {{.ServerURL}}/bitnami/mariadb:10.11.4-debian-11-r0 43 | chart: mariadb 44 | digests: 45 | - digest: sha256:d8fd0e4cdd52e10c05a165eeacdf639ac0dee12e036a62d2ab3ccec42352c7c5 46 | arch: linux/amd64 47 | - digest: sha256:7dd6e0d680eea4b7b00cef9dfe4b1c80ef7447db7ab21c51a2c3b8f7c0375ba3 48 | arch: linux/arm64 49 | 50 | -------------------------------------------------------------------------------- /pkg/chartutils/chartutils_test.go: -------------------------------------------------------------------------------- 1 | package chartutils 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 8 | 9 | "github.com/stretchr/testify/suite" 10 | ) 11 | 12 | type ChartUtilsTestSuite struct { 13 | suite.Suite 14 | sb *tu.Sandbox 15 | } 16 | 17 | func (suite *ChartUtilsTestSuite) TearDownSuite() { 18 | _ = suite.sb.Cleanup() 19 | } 20 | 21 | func (suite *ChartUtilsTestSuite) SetupSuite() { 22 | suite.sb = tu.NewSandbox() 23 | } 24 | 25 | func TestChartUtilsTestSuite(t *testing.T) { 26 | suite.Run(t, new(ChartUtilsTestSuite)) 27 | } 28 | 29 | func (suite *ChartUtilsTestSuite) TestAnnotateChart() { 30 | t := suite.T() 31 | require := suite.Require() 32 | 33 | sb := suite.sb 34 | serverURL := "localhost" 35 | scenarioName := "plain-chart" 36 | defaultAnnotationsKey := "images" 37 | // customAnnotationsKey := "artifacthub.io/images" 38 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 39 | 40 | type testImage struct { 41 | Name string 42 | Registry string 43 | Repository string 44 | Tag string 45 | Digest string 46 | } 47 | 48 | images := []testImage{ 49 | { 50 | Name: "bitnami-shell", 51 | Registry: "docker.io", 52 | Repository: "bitnami/bitnami-shell", 53 | Tag: "1.0.0", 54 | }, 55 | { 56 | Name: "wordpress", 57 | Registry: "docker.io", 58 | Repository: "bitnami/wordpress", 59 | Tag: "latest", 60 | }, 61 | } 62 | t.Run("Annotates a chart", func(t *testing.T) { 63 | chartDir := sb.TempFile() 64 | annotationsKey := defaultAnnotationsKey 65 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 66 | map[string]interface{}{"ServerURL": serverURL, "ValuesImages": images}, 67 | )) 68 | 69 | expectedImages := make([]tu.AnnotationEntry, 0) 70 | for _, img := range images { 71 | url := fmt.Sprintf("%s/%s:%s", img.Registry, img.Repository, img.Tag) 72 | expectedImages = append(expectedImages, tu.AnnotationEntry{ 73 | Name: img.Name, 74 | Image: url, 75 | }) 76 | } 77 | 78 | require.NoError(AnnotateChart(chartDir, WithAnnotationsKey(annotationsKey))) 79 | tu.AssertChartAnnotations(t, chartDir, annotationsKey, expectedImages) 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/log/logger.go: -------------------------------------------------------------------------------- 1 | // Package log defines the Logger interfaces 2 | package log 3 | 4 | import ( 5 | "io" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Level defines a type for log levels 11 | type Level logrus.Level 12 | 13 | const ( 14 | // PanicLevel level, highest level of severity. Logs and then calls panic with the 15 | // message passed to Debug, Info, ... 16 | PanicLevel = Level(logrus.PanicLevel) 17 | // FatalLevel level. Logs and then calls `logger.Exit(1)`. It will exit even if the 18 | // logging level is set to Panic. 19 | FatalLevel = Level(logrus.FatalLevel) 20 | // ErrorLevel level. Logs. Used for errors that should definitely be noted. 21 | // Commonly used for hooks to send errors to an error tracking service. 22 | ErrorLevel = Level(logrus.ErrorLevel) 23 | // WarnLevel level. Non-critical entries that deserve eyes. 24 | WarnLevel = Level(logrus.WarnLevel) 25 | // InfoLevel level. General operational entries about what's going on inside the 26 | // application. 27 | InfoLevel = Level(logrus.InfoLevel) 28 | // DebugLevel level. Usually only enabled when debugging. Very verbose logging. 29 | DebugLevel = Level(logrus.DebugLevel) 30 | // TraceLevel level. Designates finer-grained informational events than the Debug. 31 | TraceLevel = Level(logrus.TraceLevel) 32 | ) 33 | 34 | const ( 35 | // AlwaysLevel is a level to indicate we want to always log 36 | AlwaysLevel = Level(0) 37 | ) 38 | 39 | // LoggedError indicates an error that has been already logged 40 | type LoggedError struct { 41 | Err error 42 | } 43 | 44 | // Error returns the wrapped error 45 | func (e *LoggedError) Error() string { return e.Err.Error() } 46 | 47 | // Unwrap returns the wrapped error 48 | func (e *LoggedError) Unwrap() error { return e.Err } 49 | 50 | // ParseLevel returns a Level from its string representation 51 | func ParseLevel(level string) (Level, error) { 52 | l, err := logrus.ParseLevel(level) 53 | return Level(l), err 54 | } 55 | 56 | // Logger defines a common interface for loggers 57 | type Logger interface { 58 | Infof(format string, args ...interface{}) 59 | Errorf(format string, args ...interface{}) 60 | Debugf(format string, args ...interface{}) 61 | Warnf(format string, args ...interface{}) 62 | Printf(format string, args ...interface{}) 63 | SetWriter(w io.Writer) 64 | SetLevel(level Level) 65 | Failf(format string, args ...interface{}) error 66 | } 67 | -------------------------------------------------------------------------------- /internal/testutil/assert_test.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestMeasure(t *testing.T) { 13 | delay := 500 * time.Millisecond 14 | t1 := time.Now() 15 | ellapsed := Measure(func() { 16 | time.Sleep(delay) 17 | }) 18 | t2 := time.Now() 19 | assert.WithinDuration(t, t1.Add(ellapsed), t2, 10*time.Millisecond, "Measured time %v is out of the acceptable ranges", ellapsed) 20 | } 21 | 22 | func TestAssertFileExistsAndDoesnttExist(t *testing.T) { 23 | var sampleT *testing.T 24 | sb := NewSandbox() 25 | defer sb.Cleanup() 26 | 27 | sampleT = &testing.T{} 28 | AssertFileExists(sampleT, "foo") 29 | assert.True(t, sampleT.Failed()) 30 | 31 | sampleT = &testing.T{} 32 | AssertFileDoesNotExist(sampleT, "foo") 33 | assert.False(t, sampleT.Failed()) 34 | 35 | sampleT = &testing.T{} 36 | AssertFileExists(sampleT, sb.Root) 37 | assert.False(t, sampleT.Failed()) 38 | 39 | sampleT = &testing.T{} 40 | AssertFileDoesNotExist(sampleT, sb.Root) 41 | assert.True(t, sampleT.Failed()) 42 | } 43 | 44 | func TestAssertPanicsMatch(t *testing.T) { 45 | var sampleT *testing.T 46 | sampleT = &testing.T{} 47 | AssertPanicsMatch(sampleT, func() { 48 | // no panic 49 | }, regexp.MustCompile(".*")) 50 | assert.True(t, sampleT.Failed()) 51 | 52 | sampleT = &testing.T{} 53 | AssertPanicsMatch(sampleT, func() { 54 | // wrong panic message 55 | panic("Wrong error") 56 | }, regexp.MustCompile("Unexpected error.*")) 57 | assert.True(t, sampleT.Failed()) 58 | 59 | sampleT = &testing.T{} 60 | AssertPanicsMatch(sampleT, func() { 61 | // Matching error 62 | panic("Unexpected error in test") 63 | }, regexp.MustCompile("Unexpected error.*")) 64 | assert.False(t, sampleT.Failed()) 65 | } 66 | 67 | func TestAssertErrorMatch(t *testing.T) { 68 | var sampleT *testing.T 69 | sampleT = &testing.T{} 70 | // no error 71 | AssertErrorMatch(sampleT, nil, regexp.MustCompile(".*")) 72 | assert.True(t, sampleT.Failed()) 73 | 74 | sampleT = &testing.T{} 75 | // wrong error message 76 | AssertErrorMatch(sampleT, 77 | fmt.Errorf("Wrong error"), 78 | regexp.MustCompile("Unexpected error.*")) 79 | assert.True(t, sampleT.Failed()) 80 | 81 | sampleT = &testing.T{} 82 | // Matching error 83 | AssertErrorMatch(sampleT, 84 | fmt.Errorf("Unexpected error in test"), 85 | regexp.MustCompile("Unexpected error.*")) 86 | assert.False(t, sampleT.Failed()) 87 | } 88 | -------------------------------------------------------------------------------- /pkg/relocator/options.go: -------------------------------------------------------------------------------- 1 | package relocator 2 | 3 | import ( 4 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 5 | silentLog "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" 6 | 7 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 8 | ) 9 | 10 | // RelocateConfig defines the configuration used in the relocator functions 11 | type RelocateConfig struct { 12 | ImageLockConfig imagelock.Config 13 | Log log.Logger 14 | RelocateLockFile bool 15 | Recursive bool 16 | SkipImageRelocation bool 17 | ValuesFiles []string 18 | } 19 | 20 | // NewRelocateConfig returns a new RelocateConfig with default settings 21 | func NewRelocateConfig(opts ...RelocateOption) *RelocateConfig { 22 | cfg := &RelocateConfig{ 23 | Log: silentLog.NewLogger(), 24 | SkipImageRelocation: false, 25 | RelocateLockFile: true, 26 | ImageLockConfig: *imagelock.NewImagesLockConfig(), 27 | ValuesFiles: []string{"values.yaml"}, 28 | } 29 | for _, opt := range opts { 30 | opt(cfg) 31 | } 32 | 33 | return cfg 34 | } 35 | 36 | // RelocateOption defines a RelocateConfig option 37 | type RelocateOption func(*RelocateConfig) 38 | 39 | // Recursive asks relocation functions to apply to the chart dependencies recursively 40 | func Recursive(c *RelocateConfig) { 41 | c.Recursive = true 42 | } 43 | 44 | // WithAnnotationsKey customizes the annotations key used in Chart.yaml 45 | func WithAnnotationsKey(str string) func(rc *RelocateConfig) { 46 | return func(rc *RelocateConfig) { 47 | rc.ImageLockConfig.AnnotationsKey = str 48 | } 49 | } 50 | 51 | // WithSkipImageRelocation configures the SkipImageRelocation configuration 52 | func WithSkipImageRelocation(skipImageRelocation bool) func(rc *RelocateConfig) { 53 | return func(rc *RelocateConfig) { 54 | rc.SkipImageRelocation = skipImageRelocation 55 | } 56 | } 57 | 58 | // WithRelocateLockFile configures the RelocateLockFile configuration 59 | func WithRelocateLockFile(relocateLock bool) func(rc *RelocateConfig) { 60 | return func(rc *RelocateConfig) { 61 | rc.RelocateLockFile = relocateLock 62 | } 63 | } 64 | 65 | // WithLog customizes the log used by the tool 66 | func WithLog(l log.Logger) func(rc *RelocateConfig) { 67 | return func(rc *RelocateConfig) { 68 | rc.Log = l 69 | } 70 | } 71 | 72 | // WithValuesFiles configures the values files to use for relocation 73 | func WithValuesFiles(files ...string) func(rc *RelocateConfig) { 74 | return func(rc *RelocateConfig) { 75 | rc.ValuesFiles = files 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pkg/imagelock/options.go: -------------------------------------------------------------------------------- 1 | package imagelock 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Auth defines the authentication information to access the container registry 8 | type Auth struct { 9 | Username string 10 | Password string 11 | } 12 | 13 | // Config defines configuration options for ImageLock functions 14 | type Config struct { 15 | InsecureMode bool 16 | AnnotationsKey string 17 | Context context.Context 18 | Auth Auth 19 | Platforms []string 20 | SkipImageDigestResolution bool 21 | } 22 | 23 | // NewImagesLockConfig returns a new ImageLockConfig with default values 24 | func NewImagesLockConfig(opts ...Option) *Config { 25 | cfg := &Config{ 26 | AnnotationsKey: DefaultAnnotationsKey, 27 | Context: context.Background(), 28 | Platforms: make([]string, 0), 29 | } 30 | 31 | for _, opt := range opts { 32 | opt(cfg) 33 | } 34 | return cfg 35 | } 36 | 37 | // Option defines a ImageLockConfig option 38 | type Option func(*Config) 39 | 40 | // Insecure asks the tool to allow insecure HTTPS connections to the remote server. 41 | func Insecure(ic *Config) { 42 | ic.InsecureMode = true 43 | } 44 | 45 | // WithAuth provides authentication information to access the container registry 46 | func WithAuth(username, password string) func(ic *Config) { 47 | return func(ic *Config) { 48 | ic.Auth.Username = username 49 | ic.Auth.Password = password 50 | } 51 | } 52 | 53 | // WithPlatforms configures the Platforms of the Config 54 | func WithPlatforms(platforms []string) func(ic *Config) { 55 | return func(ic *Config) { 56 | ic.Platforms = platforms 57 | } 58 | } 59 | 60 | // WithInsecure configures the InsecureMode of the Config 61 | func WithInsecure(insecure bool) func(ic *Config) { 62 | return func(ic *Config) { 63 | ic.InsecureMode = insecure 64 | } 65 | } 66 | 67 | // WithContext provides an execution context 68 | func WithContext(ctx context.Context) func(ic *Config) { 69 | return func(ic *Config) { 70 | ic.Context = ctx 71 | } 72 | } 73 | 74 | // WithAnnotationsKey provides a custom annotation key to use when 75 | // reading/writing the list of images 76 | func WithAnnotationsKey(str string) func(ic *Config) { 77 | return func(ic *Config) { 78 | ic.AnnotationsKey = str 79 | } 80 | } 81 | 82 | // WithSkipImageDigestResolution configures the SkipImageDigestResolution of the Config 83 | func WithSkipImageDigestResolution(skipImageResolution bool) func(ic *Config) { 84 | return func(ic *Config) { 85 | ic.SkipImageDigestResolution = skipImageResolution 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/log/pterm/section_logger.go: -------------------------------------------------------------------------------- 1 | package pterm 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" 8 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 9 | ) 10 | 11 | const ( 12 | // Number of spaces for each nesting level 13 | nestSpacing = 3 14 | ) 15 | 16 | // NewSectionLogger returns a new SectionLogger implemented by pterm 17 | func NewSectionLogger() *SectionLogger { 18 | return &SectionLogger{Logger: NewLogger()} 19 | } 20 | 21 | // SectionLogger defines a SectionLogger using pterm 22 | type SectionLogger struct { 23 | *Logger 24 | nestLevel int 25 | } 26 | 27 | // ProgressBar returns a new ProgressBar 28 | func (l *SectionLogger) ProgressBar() log.ProgressBar { 29 | return NewProgressBar(l.prefix) 30 | } 31 | 32 | // Successf logs a new success message (more efusive than Infof) 33 | func (l *SectionLogger) Successf(format string, args ...interface{}) { 34 | l.printMessage(log.InfoLevel, Success, format, args...) 35 | } 36 | 37 | // PrefixText returns the indented version of the provided text 38 | func (l *SectionLogger) PrefixText(txt string) string { 39 | // We include a leading " " as this is intended to align with our printers, 40 | // and all printers do " " + printer.Text + " " + Text 41 | // so the extra space aligns the txt with the printer.Text char 42 | lines := make([]string, 0) 43 | for _, line := range strings.Split(txt, "\n") { 44 | lines = append(lines, fmt.Sprintf(" %s%s", l.prefix, line)) 45 | } 46 | return strings.Join(lines, "\n") 47 | } 48 | 49 | // ExecuteStep executes a function while showing an indeterminate progress animation 50 | func (l *SectionLogger) ExecuteStep(title string, fn func() error) error { 51 | err := widgets.ExecuteWithSpinner( 52 | widgets.DefaultSpinner.WithPrefix(l.prefix), 53 | title, 54 | func() error { 55 | return fn() 56 | }, 57 | ) 58 | return err 59 | } 60 | 61 | // Section executes the provided function inside a new section 62 | func (l *SectionLogger) Section(title string, fn func(log.SectionLogger) error) error { 63 | childLog := l.StartSection(title) 64 | return fn(childLog) 65 | } 66 | 67 | // StartSection starts a new log section, with nested indentation 68 | func (l *SectionLogger) StartSection(str string) log.SectionLogger { 69 | l.printMessage(log.AlwaysLevel, Fold, "%s", str) 70 | return l.nest() 71 | } 72 | 73 | func (l *SectionLogger) nest() log.SectionLogger { 74 | newLog := &SectionLogger{nestLevel: l.nestLevel + 1, Logger: NewLogger()} 75 | newLog.prefix = strings.Repeat(" ", newLog.nestLevel*nestSpacing) 76 | newLog.level = l.level 77 | return newLog 78 | } 79 | -------------------------------------------------------------------------------- /testdata/images.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Name": "apache-exporter", 4 | "Image": "bitnami/apache-exporter:0.13.4-debian-11-r2", 5 | "Digests": [ 6 | { 7 | "Arch": "linux/amd64", 8 | "Digest": "sha256:83acfe5b679dfc843692fc3d122eb7c18f4733855e0c03ba22fa301c2dbf8c05" 9 | }, 10 | { 11 | "Arch": "linux/arm64", 12 | "Digest": "sha256:50ede0624e286591351daa96b86b3e3c8826d699f931a9f854ecbc186ae6ab1c" 13 | } 14 | ] 15 | }, 16 | { 17 | "Name": "mariadb", 18 | "Image": "bitnami/mariadb:10.11.4-debian-11-r0", 19 | "Digests": [ 20 | { 21 | "Arch": "linux/amd64", 22 | "Digest": "sha256:d8fd0e4cdd52e10c05a165eeacdf639ac0dee12e036a62d2ab3ccec42352c7c5" 23 | }, 24 | { 25 | "Arch": "linux/arm64", 26 | "Digest": "sha256:7dd6e0d680eea4b7b00cef9dfe4b1c80ef7447db7ab21c51a2c3b8f7c0375ba3" 27 | } 28 | ] 29 | }, 30 | { 31 | "Name": "mysqld-exporter", 32 | "Image": "bitnami/mysqld-exporter:0.14.0-debian-11-r125", 33 | "Digests": [ 34 | { 35 | "Arch": "linux/amd64", 36 | "Digest": "sha256:3ae642840c29e2541c63871e8d9e0f8f7a8bc5c45eb0e20afa9d134242a72d12" 37 | }, 38 | { 39 | "Arch": "linux/arm64", 40 | "Digest": "sha256:82f5ebe3529a6cb3ec6a07daf819c1ee881d472fef297bb1f7c4b5d1d0634fea" 41 | } 42 | ] 43 | }, 44 | { 45 | "Name": "bitnami-shell", 46 | "Image": "bitnami/bitnami-shell:11-debian-11-r124", 47 | "Digests": [ 48 | { 49 | "Arch": "linux/amd64", 50 | "Digest": "sha256:9d9195f30a8c8a82db434acd2e9c7b02f366be17a419245a7856a61117be063f" 51 | }, 52 | { 53 | "Arch": "linux/arm64", 54 | "Digest": "sha256:296dc1939f70667553ac6d3787b5b69561a5590e2719b841e2b26d3b65ba6515" 55 | } 56 | ] 57 | }, 58 | { 59 | "Name": "bitnami-shell", 60 | "Image": "bitnami/bitnami-shell:11-debian-11-r123", 61 | "Digests": [ 62 | { 63 | "Arch": "linux/amd64", 64 | "Digest": "sha256:cc2370abc4a5d86dcaab4cb6e6e6a58f99fcc6f95b02752cabf8ba193f78d78e" 65 | }, 66 | { 67 | "Arch": "linux/arm64", 68 | "Digest": "sha256:5b7bd35e7935988160f3031766a51665bb709767d24f1e11ca44fc671446f486" 69 | } 70 | ] 71 | }, 72 | { 73 | "Name": "wordpress", 74 | "Image": "bitnami/wordpress:6.2.2-debian-11-r11", 75 | "Digests": [ 76 | { 77 | "Arch": "linux/amd64", 78 | "Digest": "sha256:a410341508b8823774448bc457730e38d2f047497ffddf090553845493c62e0f" 79 | }, 80 | { 81 | "Arch": "linux/arm64", 82 | "Digest": "sha256:1e5991a54bc98871e61dd7f94697f86b5dc4e2b2560d5590ff292038a6434ba7" 83 | } 84 | ] 85 | } 86 | ] 87 | -------------------------------------------------------------------------------- /cmd/dt/dt_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "os" 7 | "os/exec" 8 | "regexp" 9 | "syscall" 10 | "testing" 11 | 12 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/suite" 16 | ) 17 | 18 | func TestMain(m *testing.M) { 19 | if os.Getenv("BE_DT") == "1" { 20 | main() 21 | os.Exit(0) 22 | return 23 | } 24 | flag.Parse() 25 | c := m.Run() 26 | os.Exit(c) 27 | } 28 | 29 | type CmdSuite struct { 30 | suite.Suite 31 | sb *tu.Sandbox 32 | } 33 | 34 | func (suite *CmdSuite) TearDownSuite() { 35 | suite.sb.Cleanup() 36 | } 37 | 38 | func (suite *CmdSuite) AssertPanicsMatch(fn func(), re *regexp.Regexp) bool { 39 | return tu.AssertPanicsMatch(suite.T(), fn, re) 40 | } 41 | 42 | func (suite *CmdSuite) SetupSuite() { 43 | suite.sb = tu.NewSandbox() 44 | } 45 | 46 | // dt calls the dt command externally via exec 47 | func dt(cmdArgs ...string) CmdResult { 48 | return execCommand(cmdArgs...) 49 | } 50 | 51 | func TestDtToolCommand(t *testing.T) { 52 | suite.Run(t, new(CmdSuite)) 53 | } 54 | 55 | func execCommand(args ...string) CmdResult { 56 | var buffStdout, buffStderr bytes.Buffer 57 | code := 0 58 | 59 | cmd := exec.Command(os.Args[0], args...) //nolint:gosec 60 | cmd.Stdout = &buffStdout 61 | cmd.Stderr = &buffStderr 62 | 63 | cmd.Env = append(os.Environ(), "BE_DT=1") 64 | 65 | err := cmd.Run() 66 | 67 | if err != nil { 68 | code = err.(*exec.ExitError).Sys().(syscall.WaitStatus).ExitStatus() 69 | } 70 | 71 | return CmdResult{code: code, stdout: buffStdout.String(), stderr: buffStderr.String()} 72 | } 73 | 74 | type CmdResult struct { 75 | code int 76 | stdout string 77 | stderr string 78 | } 79 | 80 | func (r CmdResult) AssertErrorMatch(t *testing.T, re interface{}) bool { 81 | if r.AssertError(t) { 82 | return assert.Regexp(t, re, r.stderr) 83 | } 84 | return true 85 | } 86 | 87 | func (r CmdResult) AssertSuccessMatch(t *testing.T, re interface{}) bool { 88 | if r.AssertSuccess(t) { 89 | return assert.Regexp(t, re, r.stdout) 90 | } 91 | return true 92 | } 93 | func (r CmdResult) AssertCode(t *testing.T, code int) bool { 94 | return assert.Equal(t, code, r.code, "Expected %d code but got %d", code, r.code) 95 | } 96 | func (r CmdResult) AssertSuccess(t *testing.T) bool { 97 | return assert.True(t, r.Success(), "Expected command to success but got code=%d stderr=%s", r.code, r.stderr) 98 | } 99 | 100 | func (r CmdResult) AssertError(t *testing.T) bool { 101 | return assert.False(t, r.Success(), "Expected command to fail") 102 | } 103 | 104 | func (r CmdResult) Success() bool { 105 | return r.code == 0 106 | } 107 | -------------------------------------------------------------------------------- /cmd/dt/lock_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | func (suite *CmdSuite) TestLockCommand() { 14 | require := suite.Require() 15 | 16 | s, err := tu.NewTestServer() 17 | require.NoError(err) 18 | 19 | defer s.Close() 20 | 21 | images, err := s.LoadImagesFromFile("../../testdata/images.json") 22 | require.NoError(err) 23 | 24 | sb := suite.sb 25 | serverURL := s.ServerURL 26 | scenarioName := "custom-chart" 27 | chartName := "test" 28 | 29 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 30 | t := suite.T() 31 | 32 | t.Run("Generate lock file", func(t *testing.T) { 33 | chartDir := sb.TempFile() 34 | 35 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 36 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, 37 | )) 38 | 39 | data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), 40 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, 41 | ) 42 | require.NoError(err) 43 | var expectedLock map[string]interface{} 44 | require.NoError(yaml.Unmarshal([]byte(data), &expectedLock)) 45 | 46 | // Clear the timestamp 47 | expectedLock["metadata"] = nil 48 | 49 | // We need to provide the --insecure flag or our test server won't validate 50 | args := []string{"images", "lock", "--insecure", chartDir} 51 | res := dt(args...) 52 | res.AssertSuccess(t) 53 | 54 | newData, err := os.ReadFile(filepath.Join(chartDir, "Images.lock")) 55 | require.NoError(err) 56 | var newLock map[string]interface{} 57 | require.NoError(yaml.Unmarshal(newData, &newLock)) 58 | // Clear the timestamp 59 | newLock["metadata"] = nil 60 | 61 | require.Equal(expectedLock, newLock) 62 | 63 | }) 64 | t.Run("Errors", func(t *testing.T) { 65 | t.Run("Handles failure to write lock because of permissions", func(t *testing.T) { 66 | scenarioName := "plain-chart" 67 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 68 | 69 | chartDir := sb.TempFile() 70 | 71 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 72 | map[string]interface{}{}, 73 | )) 74 | 75 | require.NoError(os.Chmod(chartDir, os.FileMode(0555))) 76 | defer os.Chmod(chartDir, os.FileMode(0755)) 77 | 78 | args := []string{"images", "lock", "--insecure", chartDir} 79 | res := dt(args...) 80 | res.AssertErrorMatch(t, "Failed to generate lock: failed to write lock") 81 | }) 82 | t.Run("Handles non-existent chart", func(t *testing.T) { 83 | args := []string{"images", "lock", sb.TempFile()} 84 | res := dt(args...) 85 | res.AssertErrorMatch(t, "failed to obtain Images.lock location: cannot access path") 86 | }) 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/log/pterm/progress.go: -------------------------------------------------------------------------------- 1 | package pterm 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pterm/pterm" 7 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 8 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 9 | ) 10 | 11 | // ProgressBar defines a progress bar with fancy cui effects 12 | type ProgressBar struct { 13 | *pterm.ProgressbarPrinter 14 | padding string 15 | } 16 | 17 | func (p *ProgressBar) printMessage(printer *pterm.PrefixPrinter, format string, args ...interface{}) { 18 | pterm.Fprintln(printer.Writer, p.padding+printer.Sprint(fmt.Sprintf(format, args...))) 19 | } 20 | 21 | // Stop stops the progress bar 22 | func (p *ProgressBar) Stop() { 23 | _, _ = p.ProgressbarPrinter.Stop() 24 | } 25 | 26 | // Start initiates the progress bar 27 | func (p *ProgressBar) Start(title ...interface{}) (log.ProgressBar, error) { 28 | res, err := p.ProgressbarPrinter.Start(title...) 29 | if err != nil { 30 | return p, fmt.Errorf("failed to start progress bar: %w", err) 31 | } 32 | p.ProgressbarPrinter = res 33 | return p, nil 34 | } 35 | 36 | // WithTotal sets the progress bar total steps 37 | func (p *ProgressBar) WithTotal(n int) log.ProgressBar { 38 | p.ProgressbarPrinter = p.ProgressbarPrinter.WithTotal(n) 39 | return p 40 | } 41 | 42 | // Errorf shows an error message 43 | func (p *ProgressBar) Errorf(fmt string, args ...interface{}) { 44 | p.printMessage(Error, fmt, args...) 45 | } 46 | 47 | // Infof shows an info message 48 | func (p *ProgressBar) Infof(fmt string, args ...interface{}) { 49 | p.printMessage(Info, fmt, args...) 50 | } 51 | 52 | // Successf displays a success message 53 | func (p *ProgressBar) Successf(fmt string, args ...interface{}) { 54 | p.printMessage(Success, fmt, args...) 55 | } 56 | 57 | // Warnf displays a warning message 58 | func (p *ProgressBar) Warnf(fmt string, args ...interface{}) { 59 | p.printMessage(&pterm.Warning, fmt, args...) 60 | } 61 | 62 | func (p *ProgressBar) formatTitle(title string) string { 63 | // We prefix with a leading " " so we align with other printers, that 64 | // start with a leading space 65 | paddedTitle := " " + p.padding + title 66 | maxTitleLength := int(float32(p.MaxWidth) * 0.70) 67 | truncatedTitle := utils.TruncateStringWithEllipsis(paddedTitle, maxTitleLength) 68 | return fmt.Sprintf("%-*s", maxTitleLength, truncatedTitle) 69 | } 70 | 71 | // UpdateTitle updates the progress bar title 72 | func (p *ProgressBar) UpdateTitle(title string) log.ProgressBar { 73 | p.ProgressbarPrinter.UpdateTitle(p.formatTitle(title)) 74 | return p 75 | } 76 | 77 | // Add increments the progress bar the specified amount 78 | func (p *ProgressBar) Add(inc int) log.ProgressBar { 79 | p.ProgressbarPrinter.Add(inc) 80 | return p 81 | } 82 | 83 | // NewProgressBar returns a new NewProgressBar 84 | func NewProgressBar(padding string) *ProgressBar { 85 | p := pterm.DefaultProgressbar.WithMaxWidth(pterm.GetTerminalWidth()).WithRemoveWhenDone(true) 86 | 87 | return &ProgressBar{ 88 | ProgressbarPrinter: p, 89 | padding: padding, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /cmd/dt/carvelize_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 10 | carvel "github.com/vmware-labs/distribution-tooling-for-helm/pkg/carvel" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func (suite *CmdSuite) TestCarvelizeCommand() { 15 | require := suite.Require() 16 | 17 | s, err := tu.NewTestServer() 18 | require.NoError(err) 19 | 20 | defer s.Close() 21 | 22 | images, err := s.LoadImagesFromFile("../../testdata/images.json") 23 | require.NoError(err) 24 | 25 | sb := suite.sb 26 | serverURL := s.ServerURL 27 | scenarioName := "custom-chart" 28 | chartName := "test" 29 | authors := []carvel.Author{{ 30 | Name: "VMware, Inc.", 31 | Email: "dt@vmware.com", 32 | }} 33 | websites := []carvel.Website{{ 34 | URL: "https://github.com/bitnami/charts/tree/main/bitnami/wordpress", 35 | }} 36 | 37 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 38 | t := suite.T() 39 | 40 | chartDir := sb.TempFile() 41 | 42 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 43 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL, 44 | "Authors": authors, "Websites": websites, 45 | }, 46 | )) 47 | 48 | bundleData, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, ".imgpkg/bundle.yml.tmpl"), 49 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, 50 | "Authors": authors, "Websites": websites, 51 | }, 52 | ) 53 | 54 | require.NoError(err) 55 | var expectedBundle map[string]interface{} 56 | require.NoError(yaml.Unmarshal([]byte(bundleData), &expectedBundle)) 57 | 58 | carvelImagesData, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, ".imgpkg/images.yml.tmpl"), 59 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, 60 | ) 61 | require.NoError(err) 62 | var expectedCarvelImagesLock map[string]interface{} 63 | require.NoError(yaml.Unmarshal([]byte(carvelImagesData), &expectedCarvelImagesLock)) 64 | 65 | // We need to provide the --insecure flag or our test server won't validate 66 | args := []string{"charts", "carvelize", "--insecure", chartDir} 67 | res := dt(args...) 68 | res.AssertSuccess(t) 69 | 70 | t.Run("Generates Carvel bundle", func(_ *testing.T) { 71 | newBundleData, err := os.ReadFile(filepath.Join(chartDir, carvel.CarvelBundleFilePath)) 72 | require.NoError(err) 73 | var newBundle map[string]interface{} 74 | require.NoError(yaml.Unmarshal(newBundleData, &newBundle)) 75 | 76 | require.Equal(expectedBundle, newBundle) 77 | }) 78 | 79 | t.Run("Generates Carvel images", func(_ *testing.T) { 80 | newImagesData, err := os.ReadFile(filepath.Join(chartDir, carvel.CarvelImagesFilePath)) 81 | require.NoError(err) 82 | var newImagesLock map[string]interface{} 83 | require.NoError(yaml.Unmarshal(newImagesData, &newImagesLock)) 84 | 85 | require.Equal(expectedCarvelImagesLock, newImagesLock) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/dt/pull_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http/httptest" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | 13 | "github.com/google/go-containerregistry/pkg/registry" 14 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 15 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 16 | ) 17 | 18 | func (suite *CmdSuite) TestPullCommand() { 19 | t := suite.T() 20 | silentLog := log.New(io.Discard, "", 0) 21 | s := httptest.NewServer(registry.New(registry.Logger(silentLog))) 22 | defer s.Close() 23 | u, err := url.Parse(s.URL) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | imageName := "test:mytag" 29 | 30 | images, err := tu.AddSampleImagesToRegistry(imageName, u.Host) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | sb := suite.sb 36 | require := suite.Require() 37 | serverURL := u.Host 38 | scenarioName := "complete-chart" 39 | chartName := "test" 40 | 41 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 42 | 43 | createSampleChart := func(chartDir string) string { 44 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 45 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, 46 | )) 47 | return chartDir 48 | } 49 | verifyChartDir := func(chartDir string) { 50 | imagesDir := filepath.Join(chartDir, "images") 51 | suite.Require().DirExists(imagesDir) 52 | for _, imgData := range images { 53 | for _, digestData := range imgData.Digests { 54 | imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", digestData.Digest.Encoded())) 55 | suite.Assert().DirExists(imgDir) 56 | } 57 | } 58 | } 59 | t.Run("Pulls images", func(t *testing.T) { 60 | chartDir := createSampleChart(sb.TempFile()) 61 | dt("images", "pull", chartDir).AssertSuccessMatch(t, "") 62 | verifyChartDir(chartDir) 63 | }) 64 | t.Run("Pulls images and compress into filename", func(t *testing.T) { 65 | chartDir := createSampleChart(sb.TempFile()) 66 | outputFile := fmt.Sprintf("%s.tar.gz", sb.TempFile()) 67 | dt("images", "pull", "--output-file", outputFile, chartDir).AssertSuccess(t) 68 | 69 | tmpDir, err := sb.Mkdir(sb.TempFile(), 0755) 70 | require.NoError(err) 71 | 72 | require.NoError(utils.Untar(outputFile, tmpDir, utils.TarConfig{StripComponents: 1})) 73 | 74 | verifyChartDir(tmpDir) 75 | }) 76 | 77 | t.Run("Warning when no images in Images.lock", func(t *testing.T) { 78 | images = []tu.ImageData{} 79 | scenarioName := "no-images-chart" 80 | scenarioDir = fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 81 | chartDir := createSampleChart(sb.TempFile()) 82 | dt("images", "pull", chartDir).AssertSuccessMatch(t, "No images found in Images.lock") 83 | require.NoDirExists(filepath.Join(chartDir, "images")) 84 | }) 85 | 86 | t.Run("Errors", func(t *testing.T) { 87 | t.Run("Fails when Images.lock is not found", func(t *testing.T) { 88 | chartDir := createSampleChart(sb.TempFile()) 89 | require.NoError(os.RemoveAll(filepath.Join(chartDir, "Images.lock"))) 90 | 91 | dt("images", "pull", chartDir).AssertErrorMatch(t, `Failed to load Images\.lock.*`) 92 | }) 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /cmd/dt/push/push.go: -------------------------------------------------------------------------------- 1 | // Package push implements the `dt images push` command 2 | package push 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" 13 | 14 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/wrapping" 15 | ) 16 | 17 | // ChartImages pushes the images from the Images.lock 18 | func ChartImages(wrap wrapping.Wrap, imagesDir string, opts ...chartutils.Option) error { 19 | return pushImages(wrap, imagesDir, opts...) 20 | } 21 | 22 | func pushImages(wrap wrapping.Wrap, imagesDir string, opts ...chartutils.Option) error { 23 | lock, err := wrap.GetImagesLock() 24 | if err != nil { 25 | return fmt.Errorf("failed to load Images.lock: %v", err) 26 | } 27 | 28 | return chartutils.PushImages(lock, imagesDir, opts...) 29 | } 30 | 31 | // NewCmd builds a new push command 32 | func NewCmd(cfg *config.Config) *cobra.Command { 33 | var imagesDir string 34 | 35 | cmd := &cobra.Command{ 36 | Use: "push CHART_PATH", 37 | Short: "Pushes the images from Images.lock", 38 | Long: "Pushes the images found on the Images.lock from the given Helm chart path into their current registries", 39 | Example: ` # Push images from a sample local Helm chart 40 | # Images are pushed to their registries, e.g. oci://docker.io/bitnami/kafka will be pushed to DockerHub, oci://demo.goharbor.io/bitnami/redis will be pushed to Harbor 41 | $ dt images push examples/mariadb`, 42 | Args: cobra.ExactArgs(1), 43 | SilenceUsage: true, 44 | SilenceErrors: true, 45 | RunE: func(_ *cobra.Command, args []string) error { 46 | l := cfg.Logger() 47 | 48 | chartPath := args[0] 49 | 50 | ctx, cancel := cfg.ContextWithSigterm() 51 | defer cancel() 52 | 53 | chart, err := chartutils.LoadChart(chartPath) 54 | if err != nil { 55 | return fmt.Errorf("failed to load chart: %w", err) 56 | } 57 | 58 | if imagesDir == "" { 59 | imagesDir = chart.ImagesDir() 60 | } 61 | if err := l.Section("Pushing Images", func(subLog log.SectionLogger) error { 62 | if err := pushImages( 63 | chart, 64 | imagesDir, 65 | chartutils.WithLog(silent.NewLogger()), 66 | chartutils.WithContext(ctx), 67 | chartutils.WithProgressBar(subLog.ProgressBar()), 68 | chartutils.WithArtifactsDir(chart.ImageArtifactsDir()), 69 | chartutils.WithInsecureMode(cfg.Insecure), 70 | ); err != nil { 71 | return subLog.Failf("Failed to push images: %w", err) 72 | } 73 | subLog.Infof("Images pushed successfully") 74 | return nil 75 | }); err != nil { 76 | return err 77 | } 78 | 79 | l.Printf(widgets.TerminalSpacer) 80 | l.Successf("All images pushed successfully") 81 | return nil 82 | }, 83 | } 84 | cmd.PersistentFlags().StringVar(&imagesDir, "images-dir", imagesDir, 85 | "directory containing the images to push. If not empty, it overrides the default images directory inside the chart directory") 86 | return cmd 87 | } 88 | -------------------------------------------------------------------------------- /cmd/dt/info/info.go: -------------------------------------------------------------------------------- 1 | // Package info implements the dt info command 2 | package info 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 13 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 14 | ) 15 | 16 | // NewCmd returns a new dt info command 17 | func NewCmd(cfg *config.Config) *cobra.Command { 18 | var yamlFormat bool 19 | var showDetails bool 20 | 21 | cmd := &cobra.Command{ 22 | Use: "info FILE", 23 | Short: "shows info of a wrapped chart", 24 | Long: `Shows information of a wrapped Helm chart, including the bundled images and chart metadata`, 25 | Example: ` # Show information of a wrapped Helm chart 26 | $ dt info mariadb-12.2.8.wrap.tgz`, 27 | SilenceUsage: true, 28 | SilenceErrors: true, 29 | Args: cobra.ExactArgs(1), 30 | RunE: func(_ *cobra.Command, args []string) error { 31 | chartPath := args[0] 32 | l := cfg.Logger() 33 | _, _ = chartPath, l 34 | if !utils.FileExists(chartPath) { 35 | return fmt.Errorf("wrap file %q does not exist", chartPath) 36 | } 37 | lock, err := chartutils.ReadLockFromChart(chartPath) 38 | if err != nil { 39 | return fmt.Errorf("failed to load Images.lock: %v", err) 40 | } 41 | if yamlFormat { 42 | if err := lock.ToYAML(os.Stdout); err != nil { 43 | return fmt.Errorf("failed to write Images.lock yaml representation: %v", err) 44 | } 45 | } else { 46 | 47 | _ = l.Section("Wrap Information", func(l log.SectionLogger) error { 48 | l.Printf("Chart: %s", lock.Chart.Name) 49 | l.Printf("Version: %s", lock.Chart.Version) 50 | l.Printf("App Version: %s", lock.Chart.AppVersion) 51 | _ = l.Section("Metadata", func(l log.SectionLogger) error { 52 | for k, v := range lock.Metadata { 53 | l.Printf("- %s: %s", k, v) 54 | 55 | } 56 | return nil 57 | }) 58 | _ = l.Section("Images", func(l log.SectionLogger) error { 59 | for _, img := range lock.Images { 60 | if showDetails { 61 | _ = l.Section(fmt.Sprintf("%s/%s", img.Chart, img.Name), func(l log.SectionLogger) error { 62 | l.Printf("Image: %s", img.Image) 63 | if showDetails { 64 | l.Printf("Digests") 65 | for _, digest := range img.Digests { 66 | l.Printf("- Arch: %s", digest.Arch) 67 | l.Printf(" Digest: %s", digest.Digest) 68 | } 69 | } 70 | return nil 71 | }) 72 | } else { 73 | platforms := make([]string, 0) 74 | for _, digest := range img.Digests { 75 | platforms = append(platforms, digest.Arch) 76 | } 77 | l.Printf("%s (%s)", img.Image, strings.Join(platforms, ", ")) 78 | } 79 | } 80 | return nil 81 | }) 82 | return nil 83 | }) 84 | } 85 | return nil 86 | }, 87 | } 88 | cmd.PersistentFlags().BoolVar(&yamlFormat, "yaml", yamlFormat, "Show report in YAML format") 89 | cmd.PersistentFlags().BoolVar(&showDetails, "detailed", showDetails, "When using the printable report, add more details about the bundled images") 90 | 91 | return cmd 92 | } 93 | -------------------------------------------------------------------------------- /cmd/dt/login/login.go: -------------------------------------------------------------------------------- 1 | // Package login implements the command to login to OCI registries 2 | package login 3 | 4 | import ( 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | dockercfg "github.com/docker/cli/cli/config" 10 | "github.com/docker/cli/cli/config/types" 11 | "github.com/google/go-containerregistry/pkg/authn" 12 | "github.com/google/go-containerregistry/pkg/name" 13 | "github.com/spf13/cobra" 14 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 15 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 16 | ) 17 | 18 | type loginOptions struct { 19 | serverAddress string 20 | user string 21 | password string 22 | passwordStdin bool 23 | } 24 | 25 | // NewCmd returns a new dt login command 26 | func NewCmd(cfg *config.Config) *cobra.Command { 27 | var opts loginOptions 28 | 29 | cmd := &cobra.Command{ 30 | Use: "login REGISTRY", 31 | Short: "Log in to an OCI registry (Experimental)", 32 | Long: "Experimental. Log in to an OCI registry using the Docker configuration file", 33 | Example: ` # Log in to index.docker.io 34 | $ dt auth login index.docker.io -u my_username -p my_password 35 | 36 | # Log in to index.docker.io with a password from stdin 37 | $ dt auth login index.docker.io -u my_username --password-stdin < <(echo my_password)`, 38 | Args: cobra.ExactArgs(1), 39 | SilenceUsage: true, 40 | SilenceErrors: true, 41 | RunE: func(_ *cobra.Command, args []string) error { 42 | l := cfg.Logger() 43 | 44 | reg, err := name.NewRegistry(args[0]) 45 | if err != nil { 46 | return l.Failf("failed to load registry %s: %v", args[0], err) 47 | } 48 | opts.serverAddress = reg.Name() 49 | 50 | return login(opts, l) 51 | }, 52 | } 53 | 54 | flags := cmd.Flags() 55 | 56 | flags.StringVarP(&opts.user, "username", "u", "", "Username") 57 | flags.StringVarP(&opts.password, "password", "p", "", "Password") 58 | flags.BoolVarP(&opts.passwordStdin, "password-stdin", "", false, "Take the password from stdin") 59 | 60 | return cmd 61 | } 62 | 63 | // from https://github.com/google/go-containerregistry/blob/main/cmd/crane/cmd/auth.go 64 | func login(opts loginOptions, l log.SectionLogger) error { 65 | if opts.passwordStdin { 66 | contents, err := io.ReadAll(os.Stdin) 67 | if err != nil { 68 | return l.Failf("failed to read from stdin: %v", err) 69 | } 70 | 71 | opts.password = strings.TrimRight(string(contents), "\r\n") 72 | } 73 | if opts.user == "" && opts.password == "" { 74 | return l.Failf("username and password required") 75 | } 76 | l.Infof("log in to %s as user %s", opts.serverAddress, opts.user) 77 | cf, err := dockercfg.Load(os.Getenv("DOCKER_CONFIG")) 78 | if err != nil { 79 | return l.Failf("failed to load configuration: %v", err) 80 | } 81 | creds := cf.GetCredentialsStore(opts.serverAddress) 82 | if opts.serverAddress == name.DefaultRegistry { 83 | opts.serverAddress = authn.DefaultAuthKey 84 | } 85 | if err := creds.Store(types.AuthConfig{ 86 | ServerAddress: opts.serverAddress, 87 | Username: opts.user, 88 | Password: opts.password, 89 | }); err != nil { 90 | return l.Failf("failed to store credentials: %v", err) 91 | } 92 | 93 | if err := cf.Save(); err != nil { 94 | return l.Failf("failed to save authorization information: %v", err) 95 | } 96 | l.Successf("logged in via %s", cf.Filename) 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/testutil/assert.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "regexp" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "gopkg.in/yaml.v2" 12 | 13 | "helm.sh/helm/v3/pkg/chart/loader" 14 | ) 15 | 16 | // Measure executes fn and returns the time taken for it to finish 17 | func Measure(fn func()) time.Duration { 18 | t1 := time.Now() 19 | fn() 20 | t2 := time.Now() 21 | return t2.Sub(t1) 22 | } 23 | 24 | func functionAborted(fn func()) (bool, string) { 25 | aborted := false 26 | msg := "" 27 | func() { 28 | defer func() { 29 | if r := recover(); r != nil { 30 | aborted = true 31 | switch v := r.(type) { 32 | case string: 33 | msg = v 34 | case fmt.Stringer: 35 | msg = v.String() 36 | default: 37 | msg = fmt.Sprintf("%v", v) 38 | } 39 | } 40 | }() 41 | fn() 42 | }() 43 | return aborted, msg 44 | } 45 | 46 | // AssertFileExists failts the test t if path does not exists 47 | func AssertFileExists(t *testing.T, path string, msgAndArgs ...interface{}) bool { 48 | fullPath, _ := filepath.Abs(path) 49 | if fileExists(fullPath) { 50 | return true 51 | } 52 | assert.Fail(t, fmt.Sprintf("File '%s' should exist", path), msgAndArgs...) 53 | return false 54 | } 55 | 56 | // AssertFileDoesNotExist failts the test t if path exists 57 | func AssertFileDoesNotExist(t *testing.T, path string, msgAndArgs ...interface{}) bool { 58 | fullPath, _ := filepath.Abs(path) 59 | if !fileExists(fullPath) { 60 | return true 61 | } 62 | assert.Fail(t, fmt.Sprintf("File '%s' should not exist", path), msgAndArgs...) 63 | return false 64 | } 65 | 66 | // AssertPanicsMatch fails the test t if fn does not panic or if the panic 67 | // message does not match the provided regexp re 68 | func AssertPanicsMatch(t *testing.T, fn func(), re *regexp.Regexp, msgAndArgs ...interface{}) bool { 69 | if assert.Panics(t, fn, msgAndArgs...) { 70 | _, msg := functionAborted(fn) 71 | return assert.Regexp(t, re, msg, msgAndArgs...) 72 | } 73 | return false 74 | } 75 | 76 | // AssertErrorMatch fails the test t if err is nil or if its message 77 | // does not match the provided regexp re 78 | func AssertErrorMatch(t *testing.T, err error, re *regexp.Regexp, msgAndArgs ...interface{}) bool { 79 | if assert.Error(t, err, msgAndArgs...) { 80 | return assert.Regexp(t, re, err.Error(), msgAndArgs...) 81 | } 82 | return false 83 | } 84 | 85 | // AnnotationEntry defines an annotation entry 86 | type AnnotationEntry struct { 87 | Name string 88 | Image string 89 | } 90 | 91 | // AssertChartAnnotations checks if the specified chart contains the provided annotations 92 | func AssertChartAnnotations(t *testing.T, chartDir string, annotationsKey string, expectedImages []AnnotationEntry, msgAndArgs ...interface{}) bool { 93 | c, err := loader.Load(chartDir) 94 | if err != nil { 95 | assert.Fail(t, fmt.Sprintf("Failed to load chart %q: %v", chartDir, err)) 96 | return false 97 | } 98 | 99 | gotImages := make([]AnnotationEntry, 0) 100 | if err := yaml.Unmarshal([]byte(c.Metadata.Annotations[annotationsKey]), &gotImages); err != nil { 101 | assert.Fail(t, fmt.Sprintf("Failed to unmarshal chart annotations: %v", err)) 102 | return false 103 | } 104 | return assert.EqualValues(t, expectedImages, gotImages, msgAndArgs...) 105 | } 106 | -------------------------------------------------------------------------------- /cmd/dt/lock/lock.go: -------------------------------------------------------------------------------- 1 | // Package lock implements the command to create the Images.lock file 2 | package lock 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 13 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 14 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" 15 | ) 16 | 17 | // NewCmd returns a new dt lock command 18 | func NewCmd(cfg *config.Config) *cobra.Command { 19 | var platforms []string 20 | var outputFile string 21 | getOutputFilename := func(chartPath string) (string, error) { 22 | if outputFile != "" { 23 | return outputFile, nil 24 | } 25 | return chartutils.GetImageLockFilePath(chartPath) 26 | } 27 | 28 | cmd := &cobra.Command{ 29 | Use: "lock CHART_PATH", 30 | Short: "Creates the lock file", 31 | Long: "Creates the Images.lock file for the given Helm chart associating all the images at the time of locking", 32 | Example: ` # Create the Images.lock for a Helm Chart 33 | $ dt images lock examples/mariadb 34 | 35 | # Create the Images.lock from a Helm chart that uses a different annotation for specifying images 36 | $ dt images lock examples/mariadb --annotations-key artifacthub.io/images`, 37 | SilenceUsage: true, 38 | SilenceErrors: true, 39 | Args: cobra.ExactArgs(1), 40 | RunE: func(_ *cobra.Command, args []string) error { 41 | l := cfg.Logger() 42 | 43 | chartPath := args[0] 44 | 45 | lockFilePath, err := getOutputFilename(chartPath) 46 | if err != nil { 47 | return fmt.Errorf("failed to obtain Images.lock location: %w", err) 48 | } 49 | if err := l.ExecuteStep("Generating Images.lock from annotations...", func() error { 50 | return Create(chartPath, lockFilePath, silent.NewLogger(), imagelock.WithPlatforms(platforms), 51 | imagelock.WithAnnotationsKey(cfg.AnnotationsKey), 52 | imagelock.WithInsecure(cfg.Insecure)) 53 | }); err != nil { 54 | return l.Failf("Failed to generate lock: %w", err) 55 | } 56 | l.Successf("Images.lock file written to %q", lockFilePath) 57 | return nil 58 | }, 59 | } 60 | cmd.PersistentFlags().StringVar(&outputFile, "output-file", outputFile, "output file where to write the Images Lock. If empty, writes to stdout") 61 | cmd.PersistentFlags().StringSliceVar(&platforms, "platforms", platforms, "platforms to include in the Images.lock file") 62 | 63 | return cmd 64 | } 65 | 66 | // Create generates an Images.lock file from the chart annotations 67 | func Create(chartPath string, outputFile string, l log.Logger, opts ...imagelock.Option) error { 68 | l.Infof("Generating images lock for Helm chart %q", chartPath) 69 | 70 | lock, err := imagelock.GenerateFromChart(chartPath, opts...) 71 | 72 | if err != nil { 73 | return fmt.Errorf("failed to load Helm chart: %v", err) 74 | } 75 | 76 | if len(lock.Images) == 0 { 77 | l.Warnf("Did not find any image annotations at Helm chart %q", chartPath) 78 | } 79 | 80 | buff := &bytes.Buffer{} 81 | if err = lock.ToYAML(buff); err != nil { 82 | return fmt.Errorf("failed to write Images.lock file: %v", err) 83 | } 84 | 85 | if err := os.WriteFile(outputFile, buff.Bytes(), 0666); err != nil { 86 | return fmt.Errorf("failed to write lock to %q: %w", outputFile, err) 87 | } 88 | 89 | l.Infof("Images.lock file written to %q", outputFile) 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Required for globs to work correctly 2 | SHELL = /usr/bin/env bash 3 | 4 | BINDIR := $(CURDIR)/bin 5 | TOOL := dt 6 | BINNAME ?= $(TOOL) 7 | 8 | PROJECT_PLUGIN_SHORTNAME := helm-dt 9 | 10 | GOPATH ?= $(shell go env GOPATH) 11 | PATH := $(GOPATH)/bin:$(PATH) 12 | 13 | BUILD_DIR := $(abspath ./out) 14 | 15 | PKG := github.com/vmware-labs/distribution-tooling-for-helm 16 | 17 | VERSION := $(shell sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' plugin.yaml) 18 | 19 | 20 | # Rebuild the binary if any of these files change 21 | SRC := $(shell find . -type f -name '*.go' -print) go.mod go.sum 22 | 23 | GOBIN = $(shell go env GOBIN) 24 | ifeq ($(GOBIN),) 25 | GOBIN = $(shell go env GOPATH)/bin 26 | endif 27 | 28 | GOIMPORTS = $(GOBIN)/goimports 29 | GOLANGCILINT = $(GOBIN)/golangci-lint 30 | 31 | ARCH = $(shell uname -p) 32 | 33 | TAGS := 34 | TESTS := . 35 | TESTFLAGS := 36 | LDFLAGS := -w -s 37 | GOFLAGS := 38 | CGO_ENABLED ?= 0 39 | 40 | BUILD_DATE := $(shell date -u '+%Y-%m-%d %I:%M:%S UTC' 2> /dev/null) 41 | GIT_HASH := $(shell git rev-parse HEAD 2> /dev/null) 42 | 43 | LDFLAGS += -X "main.BuildDate=$(BUILD_DATE)" 44 | LDFLAGS += -X main.Commit=$(GIT_HASH) 45 | 46 | GO_MOD := @go mod 47 | 48 | 49 | HELM_3_PLUGINS = $(shell helm env HELM_PLUGINS) 50 | HELM_PLUGIN_DIR = $(HELM_3_PLUGINS)/$(PROJECT_PLUGIN_SHORTNAME) 51 | 52 | .PHONY: all 53 | all: build 54 | 55 | # ------------------------------------------------------------------------------ 56 | # build 57 | 58 | .PHONY: build 59 | build: $(BINDIR)/$(BINNAME) 60 | 61 | $(BINDIR)/$(BINNAME): $(SRC) 62 | GO111MODULE=on CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/$(TOOL) 63 | 64 | # ------------------------------------------------------------------------------ 65 | # install 66 | 67 | .PHONY: install 68 | install: build 69 | mkdir -p "$(HELM_PLUGIN_DIR)/bin" 70 | cp "$(BINDIR)/$(BINNAME)" "$(HELM_PLUGIN_DIR)/bin" 71 | cp plugin.yaml "$(HELM_PLUGIN_DIR)/" 72 | 73 | 74 | # ------------------------------------------------------------------------------ 75 | # test 76 | 77 | .PHONY: test 78 | test: build 79 | test: test-style 80 | test: test-unit 81 | 82 | .PHONY: test-unit 83 | test-unit: 84 | @echo 85 | @echo "==> Running unit tests <==" 86 | GO111MODULE=on go test $(GOFLAGS) -run $(TESTS) ./... $(TESTFLAGS) 87 | 88 | .PHONY: test-coverage 89 | test-coverage: 90 | @echo 91 | @echo "==> Running unit tests with coverage <==" 92 | @mkdir -p $(BUILD_DIR) 93 | GO111MODULE=on go test -v -covermode=count -coverprofile=$(BUILD_DIR)/cover.out ./... 94 | GO111MODULE=on go tool cover -html=$(BUILD_DIR)/cover.out -o=$(BUILD_DIR)/coverage.html 95 | 96 | .PHONY: test-style 97 | test-style: $(GOLANGCILINT) 98 | GO111MODULE=on $(GOLANGCILINT) run 99 | 100 | .PHONY: format 101 | format: $(GOIMPORTS) 102 | GO111MODULE=on go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm 103 | 104 | 105 | # ------------------------------------------------------------------------------ 106 | # dependencies 107 | 108 | $(GOLANGCILINT): 109 | (cd /; GO111MODULE=on go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.7) 110 | 111 | $(GOIMPORTS): 112 | (cd /; GO111MODULE=on go install golang.org/x/tools/cmd/goimports@latest) 113 | 114 | 115 | # ------------------------------------------------------------------------------ 116 | 117 | .PHONY: clean 118 | clean: 119 | @rm -rf '$(BINDIR)/$(BINNAME)' 120 | 121 | -------------------------------------------------------------------------------- /cmd/dt/verify/verify.go: -------------------------------------------------------------------------------- 1 | // Package verify defines the verify command 2 | package verify 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 13 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 14 | ) 15 | 16 | // Auth defines the authentication information to access the container registry 17 | type Auth struct { 18 | Username string 19 | Password string 20 | } 21 | 22 | // Config defines the configuration of the verify command 23 | type Config struct { 24 | AnnotationsKey string 25 | Insecure bool 26 | Auth Auth 27 | } 28 | 29 | // Lock verifies the images in an Images.lock 30 | func Lock(chartPath string, lockFile string, cfg Config) error { 31 | if !utils.FileExists(chartPath) { 32 | return fmt.Errorf("chart %q does not exist", chartPath) 33 | } 34 | fh, err := os.Open(lockFile) 35 | if err != nil { 36 | return fmt.Errorf("failed to open Images.lock file: %v", err) 37 | } 38 | defer fh.Close() 39 | 40 | currentLock, err := imagelock.FromYAML(fh) 41 | if err != nil { 42 | return fmt.Errorf("failed to load Images.lock: %v", err) 43 | } 44 | calculatedLock, err := imagelock.GenerateFromChart(chartPath, 45 | imagelock.WithAnnotationsKey(cfg.AnnotationsKey), 46 | imagelock.WithContext(context.Background()), 47 | imagelock.WithAuth(cfg.Auth.Username, cfg.Auth.Password), 48 | imagelock.WithInsecure(cfg.Insecure), 49 | ) 50 | 51 | if err != nil { 52 | return fmt.Errorf("failed to re-create Images.lock from Helm chart %q: %v", chartPath, err) 53 | } 54 | 55 | if err := calculatedLock.Validate(currentLock.Images); err != nil { 56 | return fmt.Errorf("validation failed for Images.lock: %w", err) 57 | } 58 | return nil 59 | } 60 | 61 | // NewCmd builds a new verify command 62 | func NewCmd(cfg *config.Config) *cobra.Command { 63 | var lockFile string 64 | 65 | cmd := &cobra.Command{ 66 | Use: "verify CHART_PATH", 67 | Short: "Verifies the images in an Images.lock", 68 | Long: "Verifies that the information in the Images.lock from the given Helm chart are the same images available on their registries for being pulled", 69 | Example: ` # Verifies integrity of the container images on the given Helm chart 70 | $ dt images verify examples/mariadb`, 71 | Args: cobra.ExactArgs(1), 72 | SilenceUsage: true, 73 | SilenceErrors: true, 74 | RunE: func(_ *cobra.Command, args []string) error { 75 | chartPath := args[0] 76 | 77 | l := cfg.Logger() 78 | 79 | if !utils.FileExists(chartPath) { 80 | return fmt.Errorf("chart %q does not exist", chartPath) 81 | } 82 | 83 | if lockFile == "" { 84 | f, err := chartutils.GetImageLockFilePath(chartPath) 85 | if err != nil { 86 | return fmt.Errorf("failed to find Images.lock file for Helm chart %q: %v", chartPath, err) 87 | } 88 | lockFile = f 89 | } 90 | 91 | if err := l.ExecuteStep("Verifying Images.lock", func() error { 92 | return Lock(chartPath, lockFile, Config{Insecure: cfg.Insecure, AnnotationsKey: cfg.AnnotationsKey}) 93 | }); err != nil { 94 | return l.Failf("failed to verify %q lock: %w", chartPath, err) 95 | } 96 | 97 | l.Successf("Helm chart %q lock is valid", chartPath) 98 | return nil 99 | }, 100 | } 101 | cmd.PersistentFlags().StringVar(&lockFile, "imagelock-file", lockFile, "location of the Images.lock YAML file") 102 | return cmd 103 | } 104 | -------------------------------------------------------------------------------- /cmd/dt/annotate_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "regexp" 7 | "testing" 8 | 9 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 10 | 11 | "helm.sh/helm/v3/pkg/chart/loader" 12 | ) 13 | 14 | type testImage struct { 15 | Name string 16 | Registry string 17 | Repository string 18 | Tag string 19 | Digest string 20 | } 21 | 22 | func (img *testImage) URL() string { 23 | return fmt.Sprintf("%s/%s:%s", img.Registry, img.Repository, img.Tag) 24 | } 25 | 26 | func (suite *CmdSuite) TestAnnotateCommand() { 27 | sb := suite.sb 28 | t := suite.T() 29 | require := suite.Require() 30 | assert := suite.Assert() 31 | 32 | serverURL := "localhost" 33 | scenarioName := "plain-chart" 34 | defaultAnnotationsKey := "images" 35 | customAnnotationsKey := "artifacthub.io/images" 36 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 37 | 38 | images := []testImage{ 39 | { 40 | Name: "bitnami-shell", 41 | Registry: "docker.io", 42 | Repository: "bitnami/bitnami-shell", 43 | Tag: "1.0.0", 44 | }, 45 | { 46 | Name: "wordpress", 47 | Registry: "docker.io", 48 | Repository: "bitnami/wordpress", 49 | Tag: "latest", 50 | }, 51 | } 52 | for title, key := range map[string]string{ 53 | "Successfully annotates a Helm chart": "", 54 | "Successfully annotates a Helm chart with custom key": customAnnotationsKey, 55 | } { 56 | t.Run(title, func(t *testing.T) { 57 | chartDir := sb.TempFile() 58 | 59 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 60 | map[string]interface{}{"ServerURL": serverURL, "ValuesImages": images}, 61 | )) 62 | var args []string 63 | if key == "" || key == defaultAnnotationsKey { 64 | // enforce it if empty 65 | if key == "" { 66 | key = defaultAnnotationsKey 67 | } 68 | args = []string{"charts", "annotate", chartDir} 69 | } else { 70 | args = []string{"charts", "--annotations-key", key, "annotate", chartDir} 71 | } 72 | dt(args...).AssertSuccess(t) 73 | 74 | expectedImages := make([]tu.AnnotationEntry, 0) 75 | for _, img := range images { 76 | expectedImages = append(expectedImages, tu.AnnotationEntry{ 77 | Name: img.Name, 78 | Image: img.URL(), 79 | }) 80 | } 81 | tu.AssertChartAnnotations(t, chartDir, key, expectedImages) 82 | }) 83 | } 84 | t.Run("Corner cases", func(t *testing.T) { 85 | t.Run("Handle empty image list case", func(t *testing.T) { 86 | chartDir := sb.TempFile() 87 | 88 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 89 | map[string]interface{}{"ServerURL": serverURL}, 90 | )) 91 | 92 | dt("charts", "annotate", chartDir).AssertSuccessMatch(t, regexp.MustCompile(`No container images found`)) 93 | 94 | tu.AssertChartAnnotations(t, chartDir, defaultAnnotationsKey, make([]tu.AnnotationEntry, 0)) 95 | 96 | c, err := loader.Load(chartDir) 97 | require.NoError(err) 98 | 99 | assert.Equal(0, len(c.Metadata.Annotations)) 100 | }) 101 | t.Run("Handle errors annotating", func(t *testing.T) { 102 | chartDir := sb.TempFile() 103 | 104 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 105 | map[string]interface{}{"ServerURL": serverURL, "ValuesImages": images}, 106 | )) 107 | require.NoError(os.Chmod(chartDir, os.FileMode(0555))) 108 | // Make sure the sandbox can be cleaned 109 | defer os.Chmod(chartDir, os.FileMode(0755)) 110 | 111 | dt("charts", "annotate", chartDir).AssertErrorMatch(t, regexp.MustCompile(`failed to annotate Helm chart.*failed to serialize.*`)) 112 | }) 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /pkg/chartutils/options.go: -------------------------------------------------------------------------------- 1 | package chartutils 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 7 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" 8 | 9 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 10 | ) 11 | 12 | // Auth defines the authentication settings 13 | type Auth struct { 14 | Username string 15 | Password string 16 | } 17 | 18 | // Configuration defines configuration settings used in chartutils functions 19 | type Configuration struct { 20 | AnnotationsKey string 21 | Log log.Logger 22 | Context context.Context 23 | ProgressBar log.ProgressBar 24 | ArtifactsDir string 25 | FetchArtifacts bool 26 | MaxRetries int 27 | InsecureMode bool 28 | Auth Auth 29 | ValuesFiles []string 30 | } 31 | 32 | // WithInsecureMode configures Insecure transport 33 | func WithInsecureMode(insecure bool) func(cfg *Configuration) { 34 | return func(cfg *Configuration) { 35 | cfg.InsecureMode = insecure 36 | } 37 | } 38 | 39 | // WithAuth configures the Auth 40 | func WithAuth(username, password string) func(cfg *Configuration) { 41 | return func(cfg *Configuration) { 42 | cfg.Auth = Auth{ 43 | Username: username, 44 | Password: password, 45 | } 46 | } 47 | } 48 | 49 | // WithArtifactsDir configures the ArtifactsDir 50 | func WithArtifactsDir(dir string) func(cfg *Configuration) { 51 | return func(cfg *Configuration) { 52 | cfg.ArtifactsDir = dir 53 | } 54 | } 55 | 56 | // WithFetchArtifacts configures the FetchArtifacts setting 57 | func WithFetchArtifacts(fetch bool) func(cfg *Configuration) { 58 | return func(cfg *Configuration) { 59 | cfg.FetchArtifacts = fetch 60 | } 61 | } 62 | 63 | // WithContext provides an execution context 64 | func WithContext(ctx context.Context) func(cfg *Configuration) { 65 | return func(cfg *Configuration) { 66 | cfg.Context = ctx 67 | } 68 | } 69 | 70 | // WithMaxRetries configures the number of retries on error 71 | func WithMaxRetries(retries int) func(cfg *Configuration) { 72 | return func(cfg *Configuration) { 73 | cfg.MaxRetries = retries 74 | } 75 | } 76 | 77 | // WithProgressBar provides a ProgressBar for long running operations 78 | func WithProgressBar(pb log.ProgressBar) func(cfg *Configuration) { 79 | return func(cfg *Configuration) { 80 | cfg.ProgressBar = pb 81 | } 82 | } 83 | 84 | // NewConfiguration returns a new Configuration 85 | func NewConfiguration(opts ...Option) *Configuration { 86 | cfg := &Configuration{ 87 | AnnotationsKey: imagelock.DefaultAnnotationsKey, 88 | Context: context.Background(), 89 | ProgressBar: silent.NewProgressBar(), 90 | ArtifactsDir: "", 91 | FetchArtifacts: false, 92 | MaxRetries: 3, 93 | Log: silent.NewLogger(), 94 | InsecureMode: false, 95 | ValuesFiles: []string{"values.yaml"}, 96 | } 97 | for _, opt := range opts { 98 | opt(cfg) 99 | } 100 | return cfg 101 | } 102 | 103 | // Option defines a configuration option 104 | type Option func(c *Configuration) 105 | 106 | // WithLog provides a log to use 107 | func WithLog(l log.Logger) func(cfg *Configuration) { 108 | return func(cfg *Configuration) { 109 | cfg.Log = l 110 | } 111 | } 112 | 113 | // WithAnnotationsKey customizes the annotations key to use when reading/writing images 114 | // to the Chart.yaml 115 | func WithAnnotationsKey(str string) func(cfg *Configuration) { 116 | return func(cfg *Configuration) { 117 | cfg.AnnotationsKey = str 118 | } 119 | } 120 | 121 | // WithValuesFiles customizes the values files in the chart 122 | func WithValuesFiles(files ...string) func(cfg *Configuration) { 123 | return func(cfg *Configuration) { 124 | cfg.ValuesFiles = files 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /cmd/dt/config/config.go: -------------------------------------------------------------------------------- 1 | // Package config defines the configuration of the dt tool 2 | package config 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "sync" 10 | "syscall" 11 | 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 13 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 14 | ll "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/logrus" 15 | 16 | pl "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/pterm" 17 | ) 18 | 19 | // Config defines the configuration of the dt tool 20 | type Config struct { 21 | Insecure bool 22 | logger log.SectionLogger 23 | Context context.Context 24 | AnnotationsKey string 25 | TempDirectory string 26 | UsePlainHTTP bool 27 | 28 | LogLevel string 29 | UsePlainLog bool 30 | } 31 | 32 | // NewConfig returns a new Config 33 | func NewConfig() *Config { 34 | return &Config{ 35 | Context: context.Background(), 36 | AnnotationsKey: imagelock.DefaultAnnotationsKey, 37 | LogLevel: "info", 38 | UsePlainLog: false, 39 | } 40 | } 41 | 42 | // Logger returns the current SectionLogger, creating it if necessary 43 | func (c *Config) Logger() log.SectionLogger { 44 | if c.logger == nil { 45 | 46 | var l log.SectionLogger 47 | if c.UsePlainLog { 48 | l = ll.NewSectionLogger() 49 | } else { 50 | l = pl.NewSectionLogger() 51 | } 52 | lvl, err := log.ParseLevel(c.LogLevel) 53 | 54 | if err != nil { 55 | l.Warnf("Invalid log level %s: %v", c.LogLevel, err) 56 | } 57 | 58 | l.SetLevel(lvl) 59 | c.logger = l 60 | } 61 | return c.logger 62 | } 63 | 64 | // GetTemporaryDirectory returns the temporary directory of the Config 65 | func (c *Config) GetTemporaryDirectory() (string, error) { 66 | if c.TempDirectory != "" { 67 | return c.TempDirectory, nil 68 | } 69 | 70 | dir, err := os.MkdirTemp("", "chart-*") 71 | if err != nil { 72 | return "", err 73 | } 74 | c.TempDirectory = dir 75 | return dir, nil 76 | } 77 | 78 | // ContextWithSigterm returns a context that is canceled when the process receives a SIGTERM 79 | func (c *Config) ContextWithSigterm() (context.Context, context.CancelFunc) { 80 | ctx, stop := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) 81 | // If we are done, call stop right away so we restore signal behavior 82 | go func() { 83 | defer stop() 84 | <-ctx.Done() 85 | }() 86 | return ctx, stop 87 | 88 | } 89 | 90 | var ( 91 | // KeepArtifacts is a flag that indicates whether artifacts should be kept 92 | KeepArtifacts bool 93 | 94 | // global temporary directory used to store different assets 95 | globalTempWorkDir string 96 | globalTempWorkDirMutex = &sync.RWMutex{} 97 | ) 98 | 99 | // CleanGlobalTempWorkDir removes the global temporary directory 100 | func CleanGlobalTempWorkDir() error { 101 | globalTempWorkDirMutex.Lock() 102 | defer globalTempWorkDirMutex.Unlock() 103 | 104 | if globalTempWorkDir == "" || KeepArtifacts { 105 | return nil 106 | } 107 | if err := os.RemoveAll(globalTempWorkDir); err != nil { 108 | return fmt.Errorf("failed to remove temporary directory %q: %w", globalTempWorkDir, err) 109 | } 110 | globalTempWorkDir = "" 111 | return nil 112 | } 113 | 114 | // GetGlobalTempWorkDir returns the current global directory or 115 | // creates a new one if none has been created yet 116 | func GetGlobalTempWorkDir() (string, error) { 117 | globalTempWorkDirMutex.Lock() 118 | defer globalTempWorkDirMutex.Unlock() 119 | 120 | if globalTempWorkDir == "" { 121 | dir, err := os.MkdirTemp("", "chart-*") 122 | if err != nil { 123 | return "", fmt.Errorf("failed to create temporary directory: %w", err) 124 | } 125 | globalTempWorkDir = dir 126 | } 127 | return globalTempWorkDir, nil 128 | } 129 | -------------------------------------------------------------------------------- /pkg/chartutils/values_test.go: -------------------------------------------------------------------------------- 1 | package chartutils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestValuesImageElement_Relocate(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | elem *ValuesImageElement 11 | prefix string 12 | expectedErr bool 13 | expectedRegistry string 14 | expectedRepo string 15 | }{ 16 | { 17 | name: "relocate with registry field with default project", 18 | elem: &ValuesImageElement{ 19 | Registry: "docker.io", 20 | Repository: "nginx", 21 | Tag: "latest", 22 | foundFields: []string{"registry", "repository", "tag"}, 23 | }, 24 | prefix: "registry.example.com/myrepo", 25 | expectedErr: false, 26 | expectedRegistry: "registry.example.com", 27 | expectedRepo: "myrepo/library/nginx", 28 | }, 29 | { 30 | name: "relocate with registry field with non default project", 31 | elem: &ValuesImageElement{ 32 | Registry: "docker.io", 33 | Repository: "redpandadata/redpanda", 34 | Tag: "latest", 35 | foundFields: []string{"registry", "repository", "tag"}, 36 | }, 37 | prefix: "007439368137.dkr.ecr.us-east-2.amazonaws.com/kafka", 38 | expectedErr: false, 39 | expectedRegistry: "007439368137.dkr.ecr.us-east-2.amazonaws.com", 40 | expectedRepo: "kafka/redpandadata/redpanda", 41 | }, 42 | { 43 | name: "relocate without registry field", 44 | elem: &ValuesImageElement{ 45 | Repository: "quay.io/cert-manager-controller", 46 | Tag: "latest", 47 | foundFields: []string{"repository", "tag"}, 48 | }, 49 | prefix: "007439368137.dkr.ecr.us-east-2.amazonaws.com", 50 | expectedErr: false, 51 | expectedRegistry: "", 52 | expectedRepo: "007439368137.dkr.ecr.us-east-2.amazonaws.com/cert-manager-controller", 53 | }, 54 | { 55 | name: "relocate without registry field and non default project", 56 | elem: &ValuesImageElement{ 57 | Repository: "quay.io/jetstack/cert-manager-controller", 58 | Tag: "latest", 59 | foundFields: []string{"repository", "tag"}, 60 | }, 61 | prefix: "007439368137.dkr.ecr.us-east-2.amazonaws.com", 62 | expectedErr: false, 63 | expectedRegistry: "", 64 | expectedRepo: "007439368137.dkr.ecr.us-east-2.amazonaws.com/jetstack/cert-manager-controller", 65 | }, 66 | { 67 | name: "relocate without registry field with default project", 68 | elem: &ValuesImageElement{ 69 | Repository: "nginx", 70 | Tag: "latest", 71 | foundFields: []string{"repository", "tag"}, 72 | }, 73 | prefix: "localhost:5000/myrepo", 74 | expectedErr: false, 75 | expectedRegistry: "localhost:5000", 76 | expectedRepo: "myrepo/library/nginx", 77 | }, 78 | { 79 | name: "relocate without registry field with non default project", 80 | elem: &ValuesImageElement{ 81 | Repository: "redpandadata/redpanda", 82 | Tag: "latest", 83 | foundFields: []string{"repository", "tag"}, 84 | }, 85 | prefix: "localhost:5000/kafka", 86 | expectedErr: false, 87 | expectedRegistry: "localhost:5000", 88 | expectedRepo: "kafka/redpandadata/redpanda", 89 | }, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | err := tt.elem.Relocate(tt.prefix) 95 | 96 | if tt.expectedErr && err == nil { 97 | t.Error("Expected error but got none") 98 | } 99 | if !tt.expectedErr && err != nil { 100 | t.Errorf("Unexpected error: %v", err) 101 | } 102 | 103 | if !tt.expectedErr { 104 | if tt.elem.Registry != tt.expectedRegistry { 105 | t.Errorf("Registry = %v, want %v", tt.elem.Registry, tt.expectedRegistry) 106 | } 107 | if tt.elem.Repository != tt.expectedRepo { 108 | t.Errorf("Repository = %v, want %v", tt.elem.Repository, tt.expectedRepo) 109 | } 110 | } 111 | }) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/chartutils/chart_test.go: -------------------------------------------------------------------------------- 1 | package chartutils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 11 | "gopkg.in/yaml.v3" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func (suite *ChartUtilsTestSuite) TestLoadChart() { 18 | sb := suite.sb 19 | t := suite.T() 20 | type rawChartData struct { 21 | Dependencies []struct { 22 | Name string 23 | Repository string 24 | Version string 25 | } 26 | Annotations map[string]string 27 | } 28 | type imgAnnotation struct { 29 | Name string 30 | Image string 31 | } 32 | readRawChart := func(f string) (*rawChartData, error) { 33 | fh, err := os.Open(f) 34 | if err != nil { 35 | return nil, fmt.Errorf("cannot open file %q: %v", f, err) 36 | } 37 | require.NoError(t, err) 38 | defer fh.Close() 39 | 40 | d := &rawChartData{} 41 | dec := yaml.NewDecoder(fh) 42 | if err := dec.Decode(d); err != nil { 43 | return nil, fmt.Errorf("cannot parse file %q: %v", f, err) 44 | } 45 | return d, nil 46 | } 47 | 48 | t.Run("Working Scenarios", func(t *testing.T) { 49 | chartDir := sb.TempFile() 50 | serverURL := "localhost" 51 | 52 | require.NoError(t, tu.RenderScenario("../../testdata/scenarios/chart1", chartDir, map[string]interface{}{"ServerURL": serverURL})) 53 | t.Run("Fail Scenarios", func(t *testing.T) { 54 | t.Run("Fails to load non existing chart", func(t *testing.T) { 55 | _, err := LoadChart(sb.TempFile()) 56 | require.ErrorContains(t, err, "no such file or directory") 57 | }) 58 | 59 | }) 60 | t.Run("Loads a chart from a directory", func(t *testing.T) { 61 | chart, err := LoadChart(chartDir) 62 | require.NoError(t, err) 63 | t.Run("RootDir", func(t *testing.T) { 64 | assert.Equal(t, chart.RootDir(), chartDir) 65 | }) 66 | t.Run("AbsFilePath", func(t *testing.T) { 67 | for _, tail := range []string{"Chart.yaml", "ImagesLock.lock"} { 68 | assert.Equal(t, chart.AbsFilePath(tail), filepath.Join(chartDir, tail)) 69 | 70 | } 71 | }) 72 | t.Run("ValuesFile", func(t *testing.T) { 73 | f := chart.ValuesFiles() 74 | require.NotNil(t, f) 75 | assert.Equal(t, len(f), 1) 76 | assert.Equal(t, f[0].Name, "values.yaml") 77 | }) 78 | t.Run("Dependencies", func(t *testing.T) { 79 | dependencies := chart.Dependencies() 80 | d, err := readRawChart(filepath.Join(chartDir, "Chart.yaml")) 81 | require.NoError(t, err) 82 | assert.Equal(t, len(dependencies), len(d.Dependencies)) 83 | OutLoop: 84 | for _, depData := range d.Dependencies { 85 | for _, dep := range dependencies { 86 | if dep.Name() == depData.Name { 87 | continue OutLoop 88 | } 89 | } 90 | assert.Fail(t, "cannot find dependant chart %q", depData.Name) 91 | } 92 | }) 93 | 94 | t.Run("GetImageAnnotations", func(t *testing.T) { 95 | res, err := chart.GetAnnotatedImages() 96 | assert.NoError(t, err) 97 | 98 | d, err := readRawChart(filepath.Join(chartDir, "Chart.yaml")) 99 | 100 | require.NoError(t, err) 101 | annotationsData, ok := d.Annotations["images"] 102 | require.True(t, ok, "Cannot find images annotation") 103 | 104 | annBuff := bytes.NewBufferString(annotationsData) 105 | imgAnnotations := make([]imgAnnotation, 0) 106 | dec := yaml.NewDecoder(annBuff) 107 | require.NoError(t, dec.Decode(&imgAnnotations)) 108 | 109 | require.Equal(t, len(imgAnnotations), len(res)) 110 | 111 | OutLoop: 112 | for _, imgAnnotation := range imgAnnotations { 113 | for _, img := range res { 114 | if img.Name == imgAnnotation.Name && img.Image == imgAnnotation.Image { 115 | continue OutLoop 116 | } 117 | } 118 | assert.Fail(t, "Image %q was not found", imgAnnotation.Name) 119 | } 120 | 121 | }) 122 | 123 | }) 124 | }) 125 | 126 | } 127 | -------------------------------------------------------------------------------- /pkg/wrapping/wrap.go: -------------------------------------------------------------------------------- 1 | // Package wrapping defines methods to handle Helm chart wraps 2 | package wrapping 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 13 | 14 | "helm.sh/helm/v3/pkg/chart/loader" 15 | ) 16 | 17 | // Lockable defines the interface to support getting images locked 18 | type Lockable interface { 19 | LockFilePath() string 20 | ImagesDir() string 21 | GetImagesLock() (*imagelock.ImagesLock, error) 22 | } 23 | 24 | // Wrap defines the interface to implement a Helm chart wrap 25 | type Wrap interface { 26 | Lockable 27 | VerifyLock(...imagelock.Option) error 28 | 29 | Chart() *chartutils.Chart 30 | RootDir() string 31 | ChartDir() string 32 | ImageArtifactsDir() string 33 | } 34 | 35 | // wrap defines a wrapped chart 36 | type wrap struct { 37 | rootDir string 38 | chart *chartutils.Chart 39 | } 40 | 41 | // RootDir returns the path to the Wrap root directory 42 | func (w *wrap) RootDir() string { 43 | return w.rootDir 44 | } 45 | 46 | // LockFilePath returns the absolute path to the chart Images.lock 47 | func (w *wrap) LockFilePath() string { 48 | return filepath.Join(w.ChartDir(), imagelock.DefaultImagesLockFileName) 49 | } 50 | 51 | // ImageArtifactsDir returns the imags artifacts directory 52 | func (w *wrap) ImageArtifactsDir() string { 53 | return filepath.Join(w.RootDir(), artifacts.HelmArtifactsFolder, "images") 54 | } 55 | 56 | // ImagesDir returns the images directory inside the chart root directory 57 | func (w *wrap) ImagesDir() string { 58 | return w.AbsFilePath("images") 59 | } 60 | 61 | // GetImagesLock returns the chart's ImagesLock object 62 | func (w *wrap) GetImagesLock() (*imagelock.ImagesLock, error) { 63 | return w.chart.GetImagesLock() 64 | } 65 | 66 | // AbsFilePath returns the absolute path to the Chart relative file name 67 | func (w *wrap) AbsFilePath(name string) string { 68 | return filepath.Join(w.rootDir, name) 69 | } 70 | 71 | // ChartDir returns the path to the Helm chart 72 | func (w *wrap) ChartDir() string { 73 | return w.chart.RootDir() 74 | } 75 | 76 | // Chart returns the Chart object 77 | func (w *wrap) Chart() *chartutils.Chart { 78 | return w.chart 79 | } 80 | 81 | func (w *wrap) VerifyLock(opts ...imagelock.Option) error { 82 | return w.chart.VerifyLock(opts...) 83 | } 84 | 85 | // Load loads a directory containing a wrapped chart and returns a Wrap 86 | func Load(dir string, opts ...chartutils.Option) (Wrap, error) { 87 | chartDir := filepath.Join(dir, "chart") 88 | chart, err := chartutils.LoadChart(chartDir, opts...) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return &wrap{rootDir: dir, chart: chart}, nil 94 | } 95 | 96 | // Create receives a path to a source Helm chart and a destination directory where to wrap it and returns a Wrap 97 | func Create(chartSrc string, destDir string, opts ...chartutils.Option) (Wrap, error) { 98 | // Check we got a chart dir 99 | if _, err := loader.Load(chartSrc); err != nil { 100 | return nil, fmt.Errorf("failed to load Helm chart: %v", err) 101 | } 102 | 103 | if err := os.MkdirAll(destDir, 0755); err != nil { 104 | return nil, fmt.Errorf("failed to create wrap root directory: %w", err) 105 | } 106 | 107 | wrapChartDir := filepath.Join(destDir, "chart") 108 | if utils.FileExists(wrapChartDir) { 109 | return nil, fmt.Errorf("chart dir %q already exists", wrapChartDir) 110 | } 111 | 112 | if err := utils.CopyDir(chartSrc, wrapChartDir); err != nil { 113 | return nil, fmt.Errorf("failed to copy source chart: %w", err) 114 | } 115 | 116 | chart, err := chartutils.LoadChart(wrapChartDir, opts...) 117 | if err != nil { 118 | return nil, fmt.Errorf("failed to load Helm chart: %w", err) 119 | } 120 | return &wrap{rootDir: destDir, chart: chart}, nil 121 | } 122 | -------------------------------------------------------------------------------- /cmd/dt/relocate_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | func readYamlFile(f string) (map[string]interface{}, error) { 14 | var data map[string]interface{} 15 | fh, err := os.Open(f) 16 | if err != nil { 17 | return nil, err 18 | } 19 | defer fh.Close() 20 | dec := yaml.NewDecoder(fh) 21 | err = dec.Decode(&data) 22 | return data, err 23 | } 24 | 25 | func (suite *CmdSuite) TestRelocateCommand() { 26 | s, err := tu.NewTestServer() 27 | suite.Require().NoError(err) 28 | defer s.Close() 29 | 30 | images, err := s.LoadImagesFromFile("../../testdata/images.json") 31 | suite.Require().NoError(err) 32 | 33 | sb := suite.sb 34 | require := suite.Require() 35 | serverURL := s.ServerURL 36 | scenarioName := "custom-chart" 37 | chartName := "test" 38 | 39 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 40 | 41 | renderLockedChart := func(chartDir string, _ string, serverURL string) string { 42 | 43 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 44 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, 45 | )) 46 | 47 | data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), 48 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, 49 | ) 50 | suite.Require().NoError(err) 51 | suite.Require().NoError(os.WriteFile(filepath.Join(chartDir, "Images.lock"), []byte(data), 0644)) 52 | return chartDir 53 | } 54 | suite.T().Run("Relocate Helm chart", func(_ *testing.T) { 55 | relocateURL := "custom.repo.example.com" 56 | originChart := renderLockedChart(sb.TempFile(), scenarioName, serverURL) 57 | expectedRelocatedDir := renderLockedChart(sb.TempFile(), scenarioName, relocateURL) 58 | cmd := dt("charts", "relocate", originChart, relocateURL) 59 | cmd.AssertSuccess(suite.T()) 60 | 61 | for _, tail := range []string{"Chart.yaml", "Images.lock"} { 62 | got, err := readYamlFile(filepath.Join(originChart, tail)) 63 | suite.Require().NoError(err) 64 | expected, err := readYamlFile(filepath.Join(expectedRelocatedDir, tail)) 65 | suite.Require().NoError(err) 66 | suite.Assert().Equal(expected, got) 67 | } 68 | }) 69 | } 70 | 71 | func (suite *CmdSuite) TestRelocateCommandRecursively() { 72 | s, err := tu.NewTestServer() 73 | suite.Require().NoError(err) 74 | defer s.Close() 75 | 76 | images, err := s.LoadImagesFromFile("../../testdata/images.json") 77 | suite.Require().NoError(err) 78 | 79 | sb := suite.sb 80 | require := suite.Require() 81 | serverURL := s.ServerURL 82 | scenarioName := "recursive-chart" 83 | chartName := "test" 84 | 85 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 86 | 87 | renderLockedChart := func(chartDir string, _ string, serverURL string) string { 88 | 89 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 90 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, 91 | )) 92 | 93 | return chartDir 94 | } 95 | suite.T().Run("Relocate Helm chart", func(_ *testing.T) { 96 | relocateURL := "custom.repo.example.com" 97 | originChart := renderLockedChart(sb.TempFile(), scenarioName, serverURL) 98 | 99 | cmd := dt("charts", "relocate", originChart, relocateURL) 100 | cmd.AssertSuccess(suite.T()) 101 | 102 | err := filepath.Walk(originChart, func(path string, info os.FileInfo, err error) error { 103 | if err != nil { 104 | return err 105 | } 106 | if !info.IsDir() && filepath.Base(path) == "Chart.yaml" { 107 | content, err := os.ReadFile(path) 108 | if err != nil { 109 | return err 110 | } 111 | suite.Assert().Contains(string(content), relocateURL, "File %s does not contain relocated URL", path) 112 | } 113 | return nil 114 | }) 115 | suite.Require().NoError(err) 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /pkg/imagelock/digest.go: -------------------------------------------------------------------------------- 1 | package imagelock 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/google/go-containerregistry/pkg/authn" 9 | "github.com/google/go-containerregistry/pkg/crane" 10 | "github.com/google/go-containerregistry/pkg/name" 11 | v1 "github.com/google/go-containerregistry/pkg/v1" 12 | "github.com/google/go-containerregistry/pkg/v1/remote" 13 | "github.com/google/go-containerregistry/pkg/v1/types" 14 | "github.com/opencontainers/go-digest" 15 | ) 16 | 17 | // DigestInfo defines the digest information for an Architecture 18 | type DigestInfo struct { 19 | Digest digest.Digest 20 | Arch string 21 | } 22 | 23 | func fetchImageDigests(r string, cfg *Config) ([]DigestInfo, error) { 24 | opts := make([]crane.Option, 0) 25 | if cfg.InsecureMode { 26 | opts = append(opts, crane.Insecure) 27 | } 28 | opts = append(opts, crane.WithContext(cfg.Context)) 29 | if cfg.Auth.Username != "" && cfg.Auth.Password != "" { 30 | opts = append(opts, crane.WithAuth(&authn.Basic{Username: cfg.Auth.Username, Password: cfg.Auth.Password})) 31 | } 32 | 33 | desc, err := GetImageRemoteDescriptor(r, opts...) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to get descriptor: %v", err) 36 | } 37 | 38 | switch desc.MediaType { 39 | 40 | case types.OCIImageIndex, types.DockerManifestList: 41 | var idx v1.IndexManifest 42 | if err := json.Unmarshal(desc.Manifest, &idx); err != nil { 43 | return nil, fmt.Errorf("failed to parse images data") 44 | } 45 | digests, err := readDigestsInfoFromIndex(idx) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to parse multi-arch image digests from remote descriptor: %w", err) 48 | } 49 | return digests, nil 50 | case types.OCIManifestSchema1, types.DockerManifestSchema2: 51 | img, err := desc.Image() 52 | if err != nil { 53 | return nil, fmt.Errorf("faild to get image from descriptor: %w", err) 54 | } 55 | digest, err := readDigestInfoFromImage(img) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to parse image digest from remote descriptor: %w", err) 58 | } 59 | return []DigestInfo{digest}, nil 60 | 61 | default: 62 | return nil, fmt.Errorf("unknown media type %q", desc.MediaType) 63 | } 64 | } 65 | 66 | // GetImageRemoteDescriptor returns the image descriptor 67 | func GetImageRemoteDescriptor(image string, opts ...crane.Option) (*remote.Descriptor, error) { 68 | o := crane.GetOptions(opts...) 69 | 70 | ref, err := name.ParseReference(image, o.Name...) 71 | 72 | if err != nil { 73 | return nil, fmt.Errorf("failed to parse reference %q: %w", image, err) 74 | } 75 | return remote.Get(ref, o.Remote...) 76 | } 77 | 78 | func readDigestsInfoFromIndex(idx v1.IndexManifest) ([]DigestInfo, error) { 79 | digests := make([]DigestInfo, 0) 80 | 81 | var allErrors error 82 | 83 | for _, img := range idx.Manifests { 84 | // Skip attestations 85 | if img.Annotations["vnd.docker.reference.type"] == "attestation-manifest" { 86 | continue 87 | } 88 | switch img.MediaType { 89 | case types.OCIManifestSchema1, types.DockerManifestSchema2: 90 | platform := img.Platform 91 | if platform == nil { 92 | allErrors = errors.Join(allErrors, fmt.Errorf("image does not define a platform")) 93 | continue 94 | } 95 | imgDigest := DigestInfo{ 96 | Digest: digest.Digest(img.Digest.String()), 97 | Arch: fmt.Sprintf("%s/%s", platform.OS, platform.Architecture), 98 | } 99 | digests = append(digests, imgDigest) 100 | default: 101 | allErrors = errors.Join(allErrors, fmt.Errorf("unknown media type %q", img.MediaType)) 102 | continue 103 | } 104 | } 105 | return digests, allErrors 106 | } 107 | 108 | func readDigestInfoFromImage(img v1.Image) (DigestInfo, error) { 109 | conf, err := img.ConfigFile() 110 | if err != nil { 111 | return DigestInfo{}, fmt.Errorf("faild to get image config: %w", err) 112 | } 113 | 114 | platform := conf.Platform() 115 | if platform == nil { 116 | return DigestInfo{}, fmt.Errorf("failed to obtain image platform") 117 | } 118 | 119 | digestData, err := img.Digest() 120 | if err != nil { 121 | return DigestInfo{}, fmt.Errorf("failed to get image digest: %w", err) 122 | } 123 | 124 | return DigestInfo{ 125 | Arch: fmt.Sprintf("%s/%s", platform.OS, platform.Architecture), 126 | Digest: digest.Digest(digestData.String()), 127 | }, nil 128 | } 129 | -------------------------------------------------------------------------------- /cmd/dt/pull/pull.go: -------------------------------------------------------------------------------- 1 | // Package pull implements the pull command 2 | package pull 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 13 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/wrapping" 14 | ) 15 | 16 | // ChartImages pulls the images of a Helm chart 17 | func ChartImages(wrap wrapping.Wrap, imagesDir string, opts ...chartutils.Option) error { 18 | return pullImages(wrap, imagesDir, opts...) 19 | } 20 | 21 | // NewCmd builds a new pull command 22 | func NewCmd(cfg *config.Config) *cobra.Command { 23 | var outputFile string 24 | var imagesDir string 25 | 26 | cmd := &cobra.Command{ 27 | Use: "pull CHART_PATH", 28 | Short: "Pulls the images from the Images.lock", 29 | Long: "Pulls all the images that are defined within the Images.lock from the given Helm chart", 30 | Example: ` # Pull images from a Helm Chart in a local folder 31 | $ dt images pull examples/mariadb`, 32 | Args: cobra.ExactArgs(1), 33 | SilenceUsage: true, 34 | SilenceErrors: true, 35 | RunE: func(_ *cobra.Command, args []string) error { 36 | chartPath := args[0] 37 | l := cfg.Logger() 38 | 39 | // TODO: Implement timeout 40 | 41 | ctx, cancel := cfg.ContextWithSigterm() 42 | defer cancel() 43 | 44 | chart, err := chartutils.LoadChart(chartPath) 45 | if err != nil { 46 | return fmt.Errorf("failed to load chart: %w", err) 47 | } 48 | if imagesDir == "" { 49 | imagesDir = chart.ImagesDir() 50 | } 51 | lock, err := chart.GetImagesLock() 52 | if err != nil { 53 | return l.Failf("Failed to load Images.lock: %v", err) 54 | } 55 | if len(lock.Images) == 0 { 56 | l.Warnf("No images found in Images.lock") 57 | } else { 58 | if err := l.Section(fmt.Sprintf("Pulling images into %q", chart.ImagesDir()), func(childLog log.SectionLogger) error { 59 | if err := pullImages( 60 | chart, 61 | imagesDir, 62 | chartutils.WithLog(childLog), 63 | chartutils.WithContext(ctx), 64 | chartutils.WithProgressBar(childLog.ProgressBar()), 65 | chartutils.WithArtifactsDir(chart.ImageArtifactsDir()), 66 | chartutils.WithInsecureMode(cfg.Insecure), 67 | ); err != nil { 68 | return childLog.Failf("%v", err) 69 | } 70 | childLog.Infof("All images pulled successfully") 71 | return nil 72 | }); err != nil { 73 | return l.Failf("%w", err) 74 | } 75 | } 76 | 77 | if outputFile != "" { 78 | if err := l.ExecuteStep( 79 | fmt.Sprintf("Compressing chart into %q", outputFile), 80 | func() error { 81 | return utils.TarContext(ctx, chart.RootDir(), outputFile, utils.TarConfig{ 82 | Prefix: fmt.Sprintf("%s-%s", chart.Name(), chart.Version()), 83 | }) 84 | }, 85 | ); err != nil { 86 | return l.Failf("failed to compress chart: %w", err) 87 | } 88 | 89 | l.Infof("Helm chart compressed to %q", outputFile) 90 | } 91 | 92 | var successMessage string 93 | if outputFile != "" { 94 | successMessage = fmt.Sprintf("All images pulled successfully and chart compressed into %q", outputFile) 95 | } else { 96 | successMessage = fmt.Sprintf("All images pulled successfully into %q", chart.ImagesDir()) 97 | } 98 | 99 | l.Printf(widgets.TerminalSpacer) 100 | l.Successf(successMessage) 101 | 102 | return nil 103 | }, 104 | } 105 | cmd.PersistentFlags().StringVar(&outputFile, "output-file", outputFile, "generate a tar.gz with the output of the pull operation") 106 | cmd.PersistentFlags().StringVar(&imagesDir, "images-dir", imagesDir, 107 | "directory where the images will be pulled to. If not empty, it overrides the default images directory inside the chart directory") 108 | return cmd 109 | } 110 | 111 | func pullImages(chart wrapping.Lockable, imagesDir string, opts ...chartutils.Option) error { 112 | lock, err := chart.GetImagesLock() 113 | if err != nil { 114 | return fmt.Errorf("failed to read Images.lock file") 115 | } 116 | if err := chartutils.PullImages(lock, imagesDir, 117 | opts..., 118 | ); err != nil { 119 | return fmt.Errorf("failed to pull images: %v", err) 120 | } 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /internal/testutil/sandbox.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "log" 5 | "math/rand" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | var ( 14 | tempFileIndex = 0 15 | mutex = &sync.Mutex{} 16 | ) 17 | 18 | // Sandbox allows manipulating files and directories with paths sandboxed into 19 | // the Root directory 20 | type Sandbox struct { 21 | sync.RWMutex 22 | // Root of the sandbox 23 | Root string 24 | temporaryResources []string 25 | } 26 | 27 | // NewSandbox returns a new sandbox with the configured root or a random 28 | // temporary one if none is provided 29 | func NewSandbox(args ...string) *Sandbox { 30 | var root string 31 | var err error 32 | if len(args) > 0 { 33 | root = args[0] 34 | } else { 35 | root, err = os.MkdirTemp("", "sandbox") 36 | if err != nil { 37 | log.Fatal("Error creating temporary directory for sandbox") 38 | } 39 | } 40 | sb := &Sandbox{Root: root} 41 | sb.temporaryResources = make([]string, 0) 42 | return sb 43 | } 44 | 45 | // Track registers a path as a temporary one to be deleted on cleanup 46 | func (sb *Sandbox) Track(p string) { 47 | defer sb.Unlock() 48 | sb.Lock() 49 | sb.temporaryResources = append(sb.temporaryResources, sb.Normalize(p)) 50 | } 51 | 52 | // Touch touches a file inside the sandbox 53 | func (sb *Sandbox) Touch(file string) string { 54 | f := sb.Normalize(file) 55 | if fileExists(f) { 56 | os.Chtimes(f, time.Now(), time.Now()) 57 | } else { 58 | sb.WriteFile(f, []byte{}, os.FileMode(0766)) 59 | } 60 | return f 61 | } 62 | 63 | // TempFile returns a temporary non-existent file. 64 | // An optional file tail can be provided 65 | func (sb *Sandbox) TempFile(args ...string) string { 66 | tail := "" 67 | if len(args) > 0 { 68 | tail = args[0] 69 | } else { 70 | tail = strconv.Itoa(rand.Int()) //nolint:gosec 71 | // Too long paths in osx result in errors creating sockets (make the daemon tests break) 72 | // https://github.com/golang/go/issues/6895 73 | if len(tail) > 10 { 74 | tail = tail[0:10] 75 | } 76 | } 77 | mutex.Lock() 78 | tail += strconv.Itoa(tempFileIndex) 79 | tempFileIndex++ 80 | mutex.Unlock() 81 | 82 | f := sb.Normalize(tail) 83 | if fileExists(f) { 84 | suffix := 0 85 | for fileExists(f + strconv.Itoa(suffix)) { 86 | suffix++ 87 | } 88 | f = f + strconv.Itoa(suffix) 89 | } 90 | 91 | sb.Track(f) 92 | return f 93 | } 94 | 95 | // Mkdir creates a directory inside the sandbox 96 | func (sb *Sandbox) Mkdir(p string, mode os.FileMode) (string, error) { 97 | f := sb.Normalize(p) 98 | sb.Track(f) 99 | return f, os.MkdirAll(f, mode) 100 | } 101 | 102 | // Symlink creates a symlink inside the sandbox 103 | func (sb *Sandbox) Symlink(oldname, newname string) (string, error) { 104 | dest := sb.Normalize(newname) 105 | sb.Track(dest) 106 | return dest, os.Symlink(oldname, dest) 107 | } 108 | 109 | // Write writes data into the file pointed by path. 110 | // This is a convenience wrapper around WriteFile 111 | func (sb *Sandbox) Write(path string, data string) (string, error) { 112 | return sb.WriteFile(path, []byte(data), os.FileMode(0644)) 113 | } 114 | 115 | // WriteFile writes a set of bytes (data) into the file pointed by path and with the specified mode 116 | func (sb *Sandbox) WriteFile(path string, data []byte, mode os.FileMode) (string, error) { 117 | f := sb.Normalize(path) 118 | sb.Track(f) 119 | return f, os.WriteFile(f, data, mode) 120 | } 121 | 122 | // Cleanup removes all the resources created by the sandbox 123 | func (sb *Sandbox) Cleanup() error { 124 | sb.RLock() 125 | resources := sb.temporaryResources 126 | sb.RUnlock() 127 | for _, p := range resources { 128 | os.RemoveAll(p) 129 | } 130 | return os.RemoveAll(sb.Root) 131 | } 132 | 133 | // ContainsPath returns true if path is contained inside the sandbox and false otherwise. 134 | // This function does not check for the existence of the file, just checks if the 135 | // path is contained in the sanbox root 136 | func (sb *Sandbox) ContainsPath(path string) bool { 137 | splitted := fileSplit(path) 138 | for idx, comp := range fileSplit(sb.Root) { 139 | if idx >= len(splitted) || comp != splitted[idx] { 140 | return false 141 | } 142 | } 143 | return true 144 | } 145 | 146 | // Normalize returns the fully normalized version of path, including the Root prefix 147 | func (sb *Sandbox) Normalize(path string) string { 148 | if sb.ContainsPath(path) { 149 | return path 150 | } 151 | return filepath.Join(sb.Root, path) 152 | } 153 | -------------------------------------------------------------------------------- /cmd/dt/verify_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/opencontainers/go-digest" 10 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 12 | ) 13 | 14 | func (suite *CmdSuite) TestVerifyCommand() { 15 | t := suite.T() 16 | sb := suite.sb 17 | require := suite.Require() 18 | 19 | s, err := tu.NewTestServer() 20 | suite.Require().NoError(err) 21 | 22 | defer s.Close() 23 | 24 | serverURL := s.ServerURL 25 | 26 | renderLockedChart := func(chartDir string, chartName string, scenarioName string, serverURL string, images []*tu.ImageData) string { 27 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 28 | 29 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 30 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, 31 | )) 32 | 33 | data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), 34 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, 35 | ) 36 | require.NoError(err) 37 | require.NoError(os.WriteFile(filepath.Join(chartDir, "Images.lock"), []byte(data), 0644)) 38 | return chartDir 39 | } 40 | 41 | t.Run("Handle errors", func(t *testing.T) { 42 | t.Run("Non-existent Helm chart", func(t *testing.T) { 43 | dt("images", "verify", sb.TempFile()).AssertErrorMatch(t, "chart.*does not exist") 44 | }) 45 | t.Run("Missing Images.lock", func(t *testing.T) { 46 | chartName := "test" 47 | scenarioName := "plain-chart" 48 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 49 | chartDir := sb.TempFile() 50 | 51 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 52 | map[string]interface{}{ 53 | "ServerURL": serverURL, "Images": nil, 54 | "Name": chartName, "RepositoryURL": serverURL, 55 | }, 56 | )) 57 | dt("images", "verify", chartDir).AssertErrorMatch(t, "failed to open Images.lock file") 58 | }) 59 | t.Run("Handle malformed Images.lock", func(t *testing.T) { 60 | chartName := "test" 61 | scenarioName := "plain-chart" 62 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 63 | chartDir := sb.TempFile() 64 | 65 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 66 | map[string]interface{}{ 67 | "ServerURL": serverURL, "Images": nil, 68 | "Name": chartName, "RepositoryURL": serverURL, 69 | }, 70 | )) 71 | require.NoError(os.WriteFile(filepath.Join(chartDir, imagelock.DefaultImagesLockFileName), []byte("malformed lock"), 0644)) 72 | dt("images", "verify", chartDir).AssertErrorMatch(t, "failed to load Images.lock") 73 | }) 74 | t.Run("Handle verify error", func(t *testing.T) { 75 | images, err := s.LoadImagesFromFile("../../testdata/images.json") 76 | require.NoError(err) 77 | scenarioName := "custom-chart" 78 | chartName := "test" 79 | 80 | chartDir := renderLockedChart(sb.TempFile(), chartName, scenarioName, serverURL, images) 81 | // Modify images and override lock file 82 | newDigest := digest.Digest("sha256:0000000000000000000000000000000000000000000000000000000000000000") 83 | oldDigest := images[0].Digests[0].Digest 84 | images[0].Digests[0].Digest = newDigest 85 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 86 | 87 | data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), 88 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, 89 | ) 90 | require.NoError(err) 91 | require.NoError(os.WriteFile(filepath.Join(chartDir, "Images.lock"), []byte(data), 0644)) 92 | dt("images", "verify", "--insecure", chartDir).AssertErrorMatch(t, 93 | fmt.Sprintf( 94 | `.*validation failed for Images.lock:.*chart "test": image ".*%s": digests do not match:\s*.*- %s\s*\s*\+ %s.*`, 95 | images[0].Image, newDigest, oldDigest, 96 | ), 97 | ) 98 | }) 99 | }) 100 | t.Run("Verify Helm chart", func(t *testing.T) { 101 | images, err := s.LoadImagesFromFile("../../testdata/images.json") 102 | require.NoError(err) 103 | 104 | scenarioName := "custom-chart" 105 | chartName := "test" 106 | originChart := renderLockedChart(sb.TempFile(), chartName, scenarioName, serverURL, images) 107 | 108 | dt("images", "verify", "--insecure", originChart).AssertSuccessMatch(t, "") 109 | }) 110 | 111 | } 112 | -------------------------------------------------------------------------------- /internal/testutil/server.go: -------------------------------------------------------------------------------- 1 | // Package testutil implements functions used to test the different packages 2 | package testutil 3 | 4 | import ( 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "net/http/httptest" 12 | "net/url" 13 | "os" 14 | "strings" 15 | "text/template" 16 | 17 | "github.com/Masterminds/sprig/v3" 18 | "github.com/opencontainers/go-digest" 19 | ) 20 | 21 | // TestServer defines a images registry for testing 22 | type TestServer struct { 23 | ServerURL string 24 | s *httptest.Server 25 | responsesMap map[string]response 26 | } 27 | 28 | // DigestData defines Digest information for an Architecture 29 | type DigestData struct { 30 | Arch string 31 | Digest digest.Digest 32 | } 33 | 34 | // ImageData defines information for a docker image 35 | type ImageData struct { 36 | Name string 37 | Image string 38 | Digests []DigestData 39 | } 40 | 41 | // AddImage adds information for an image to the server so it can be later queried 42 | func (s *TestServer) AddImage(img *ImageData) error { 43 | imgID := img.Image 44 | parts := strings.SplitN(imgID, ":", 2) 45 | if len(parts) != 2 { 46 | return fmt.Errorf("failed to process image id: cannot find tag") 47 | } 48 | url := fmt.Sprintf("/v2/%s/manifests/%s", parts[0], parts[1]) 49 | 50 | s.responsesMap[url] = response{ 51 | ContentType: "application/vnd.docker.distribution.manifest.list.v2+json", 52 | Body: manifestResponse(img), 53 | } 54 | return nil 55 | } 56 | 57 | // Close shuts down the test server 58 | func (s *TestServer) Close() { 59 | s.s.Close() 60 | } 61 | 62 | // LoadImagesFromFile adds the images specified in the JSON file provided to the server 63 | func (s *TestServer) LoadImagesFromFile(file string) ([]*ImageData, error) { 64 | var allErrors error 65 | var referenceImages []*ImageData 66 | 67 | fh, err := os.Open(file) 68 | if err != nil { 69 | return nil, err 70 | } 71 | defer fh.Close() 72 | dec := json.NewDecoder(fh) 73 | if err := dec.Decode(&referenceImages); err != nil { 74 | return nil, fmt.Errorf("failed to decode reference images: %w", err) 75 | } 76 | 77 | for _, img := range referenceImages { 78 | if err := s.AddImage(img); err != nil { 79 | allErrors = errors.Join(allErrors, err) 80 | } 81 | } 82 | return referenceImages, allErrors 83 | } 84 | 85 | // NewTestServer returns a new TestServer 86 | func NewTestServer() (*TestServer, error) { 87 | testServer := &TestServer{responsesMap: make(map[string]response)} 88 | 89 | s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 90 | if strings.Contains(r.URL.Path, "manifests") { 91 | resp, ok := testServer.responsesMap[r.URL.Path] 92 | if !ok { 93 | w.WriteHeader(404) 94 | if _, err := fmt.Fprintf(w, "cannot find image %q", r.URL.Path); err != nil { 95 | log.Fatal(err) 96 | } 97 | return 98 | } 99 | w.Header().Set("Content-Type", resp.ContentType) 100 | w.WriteHeader(200) 101 | _, err := w.Write([]byte(resp.Body)) 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | } else if r.URL.Path == "/v2/" { 106 | w.WriteHeader(200) 107 | } else { 108 | w.WriteHeader(500) 109 | } 110 | })) 111 | testServer.s = s 112 | u, _ := url.Parse(s.URL) 113 | testServer.ServerURL = fmt.Sprintf("localhost:%s", u.Port()) 114 | return testServer, nil 115 | } 116 | 117 | type response struct { 118 | Body string 119 | ContentType string 120 | } 121 | 122 | func manifestResponse(img *ImageData) string { 123 | tmpl, err := template.New("test").Funcs(fns).Funcs(sprig.FuncMap()).Parse(` 124 | {{$listLen:= len .Digests}} 125 | { 126 | "schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json", 127 | "manifests":[ 128 | {{- range $i, $e := .Digests}} 129 | {{- $archList := splitList "/" $e.Arch }} 130 | {{- $os := index $archList 0 }} 131 | {{- $arch := index $archList 1 }} 132 | { 133 | "mediaType":"application/vnd.docker.distribution.manifest.v2+json", 134 | "size":430,"digest":"{{$e.Digest}}", 135 | "platform":{"architecture":"{{$arch}}","os":"{{$os}}"} 136 | }{{if not (isLast $i $listLen)}},{{end}} 137 | {{- end}} 138 | ] 139 | }`) 140 | if err != nil { 141 | log.Fatal(err) 142 | 143 | } 144 | b := &bytes.Buffer{} 145 | 146 | if err := tmpl.Execute(b, img); err != nil { 147 | log.Fatal(err) 148 | } 149 | 150 | _ = tmpl 151 | return strings.TrimSpace(b.String()) 152 | } 153 | -------------------------------------------------------------------------------- /cmd/dt/info_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 11 | ) 12 | 13 | func (suite *CmdSuite) TestInfoCommand() { 14 | t := suite.T() 15 | require := suite.Require() 16 | assert := suite.Assert() 17 | 18 | sb := suite.sb 19 | 20 | t.Run("Get Wrap Info", func(t *testing.T) { 21 | imageName := "test" 22 | imageTag := "mytag" 23 | 24 | serverURL := "localhost" 25 | scenarioName := "complete-chart" 26 | chartName := "test" 27 | version := "1.0.0" 28 | appVersion := "2.3.4" 29 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 30 | 31 | wrapDir := sb.TempFile() 32 | chartDir := sb.TempFile() 33 | 34 | images, err := writeSampleImages(imageName, imageTag, filepath.Join(wrapDir, "images")) 35 | require.NoError(err) 36 | err = utils.CopyDir(filepath.Join(wrapDir, "images"), chartDir) 37 | require.NoError(err) 38 | 39 | for _, chartPath := range []string{filepath.Join(wrapDir, "chart"), chartDir} { 40 | require.NoError(tu.RenderScenario(scenarioDir, chartPath, 41 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version, "AppVersion": appVersion, "RepositoryURL": serverURL}, 42 | )) 43 | } 44 | tarFile := sb.TempFile() 45 | if err := utils.Tar(wrapDir, tarFile, utils.TarConfig{ 46 | Prefix: chartName, 47 | }); err != nil { 48 | require.NoError(err) 49 | } 50 | for _, inputChart := range []string{tarFile, chartDir} { 51 | t.Run("Short info", func(t *testing.T) { 52 | var archList []string 53 | for _, digest := range images[0].Digests { 54 | archList = append(archList, digest.Arch) 55 | } 56 | 57 | res := dt("info", inputChart) 58 | res.AssertSuccess(t) 59 | imageURL := fmt.Sprintf("%s/%s:%s", serverURL, imageName, imageTag) 60 | 61 | imageEntryRe := fmt.Sprintf(`%s\s+\(%s\)`, imageURL, strings.Join(archList, ", ")) 62 | assert.Regexp(fmt.Sprintf(`(?s).*Wrap Information.*Chart:.*%s\s*.*Version:.*%s.*%s\s*.*Metadata.*Images.*%s`, chartName, version, appVersion, imageEntryRe), res.stdout) 63 | }) 64 | t.Run("Detailed info", func(t *testing.T) { 65 | res := dt("info", "--detailed", inputChart) 66 | res.AssertSuccess(t) 67 | imageURL := fmt.Sprintf("%s/%s:%s", serverURL, imageName, imageTag) 68 | 69 | imgDetailedInfo := fmt.Sprintf(`%s/%s.*Image:\s+%s.*Digests.*`, chartName, imageName, imageURL) 70 | for _, digest := range images[0].Digests { 71 | imgDetailedInfo += fmt.Sprintf(`.*- Arch:\s+%s.*Digest:\s+%s.*`, digest.Arch, digest.Digest) 72 | } 73 | assert.Regexp(fmt.Sprintf(`(?s).*Wrap Information.*Chart:.*%s\s*.*Version:.*%s.*Metadata.*Images.*%s`, chartName, version, imgDetailedInfo), res.stdout) 74 | }) 75 | t.Run("YAML format", func(t *testing.T) { 76 | res := dt("info", "--yaml", inputChart) 77 | res.AssertSuccess(t) 78 | data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), 79 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version, "AppVersion": appVersion}, 80 | ) 81 | require.NoError(err) 82 | 83 | lockFileData, err := tu.NormalizeYAML(data) 84 | require.NoError(err) 85 | yamlInfoData, err := tu.NormalizeYAML(res.stdout) 86 | require.NoError(err) 87 | 88 | assert.Equal(lockFileData, yamlInfoData) 89 | }) 90 | 91 | } 92 | }) 93 | t.Run("Errors", func(t *testing.T) { 94 | serverURL := "localhost" 95 | scenarioName := "plain-chart" 96 | chartName := "test" 97 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 98 | chartDir := sb.TempFile() 99 | 100 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 101 | map[string]interface{}{"ServerURL": serverURL}, 102 | )) 103 | 104 | tarFile := sb.TempFile() 105 | if err := utils.Tar(chartDir, tarFile, utils.TarConfig{ 106 | Prefix: chartName, 107 | }); err != nil { 108 | require.NoError(err) 109 | } 110 | for _, inputChart := range []string{tarFile, chartDir} { 111 | t.Run("Fails when missing Images.lock", func(t *testing.T) { 112 | dt("info", inputChart).AssertErrorMatch(t, "failed to load Images.lock") 113 | }) 114 | } 115 | t.Run("Handles non-existent wraps", func(t *testing.T) { 116 | dt("info", sb.TempFile()).AssertErrorMatch(t, `wrap file.* does not exist`) 117 | }) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /internal/testutil/cosign.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "fmt" 11 | "os" 12 | "strings" 13 | 14 | "github.com/DataDog/go-tuf/encrypted" 15 | "github.com/google/go-containerregistry/pkg/crane" 16 | "github.com/google/go-containerregistry/pkg/name" 17 | "github.com/google/go-containerregistry/pkg/v1/remote" 18 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" 19 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" 20 | "github.com/sigstore/cosign/v2/cmd/cosign/cli/verify" 21 | "github.com/sigstore/cosign/v2/pkg/cosign" 22 | ) 23 | 24 | // resolveImage gets a image and returns its resolved tag version 25 | func resolveImage(image string, opts ...crane.Option) (string, error) { 26 | o := crane.GetOptions(opts...) 27 | 28 | ref, err := name.ParseReference(image) 29 | if err != nil { 30 | return "", fmt.Errorf("failed to parse image reference: %w", err) 31 | } 32 | 33 | switch v := ref.(type) { 34 | case name.Tag: 35 | desc, err := remote.Get(ref, o.Remote...) 36 | if err != nil { 37 | return "", fmt.Errorf("failed to get remote descriptor: %w", err) 38 | } 39 | return fmt.Sprintf("%s@%s", ref.Context().Name(), desc.Digest), nil 40 | case name.Digest: 41 | // We already got a digest 42 | return image, nil 43 | default: 44 | return "", fmt.Errorf("unsupported reference type %T", v) 45 | } 46 | } 47 | 48 | // CosignImage signs a remote artifact with the provided key 49 | func CosignImage(url string, key string, opts ...crane.Option) error { 50 | o := crane.GetOptions(opts...) 51 | url = strings.TrimPrefix(url, "oci://") 52 | // cosign complains if we sign a tag with 53 | // WARNING: Image reference 127.0.0.1/test:mytag uses a tag, not a digest, to identify the image to sign. 54 | image, err := resolveImage(url, opts...) 55 | if err != nil { 56 | return fmt.Errorf("failed to sign %q: %v", url, err) 57 | } 58 | return sign.SignCmd( 59 | &options.RootOptions{Timeout: options.DefaultTimeout, Verbose: false}, 60 | options.KeyOpts{KeyRef: key}, 61 | options.SignOptions{Upload: true, Registry: options.RegistryOptions{RegistryClientOpts: o.Remote}}, 62 | []string{image}, 63 | ) 64 | } 65 | 66 | // CosignVerifyImage verifies a remote artifact signature with the provided key 67 | func CosignVerifyImage(url string, key string, opts ...crane.Option) error { 68 | o := crane.GetOptions(opts...) 69 | url = strings.TrimPrefix(url, "oci://") 70 | 71 | v := &verify.VerifyCommand{ 72 | RegistryOptions: options.RegistryOptions{RegistryClientOpts: o.Remote}, 73 | KeyRef: key, 74 | IgnoreTlog: true, 75 | } 76 | v.NameOptions = append(v.NameOptions, name.Insecure) 77 | ctx := context.Background() 78 | return v.Exec(ctx, []string{url}) 79 | } 80 | 81 | func writeTempFile(dir, name string, data []byte) (*os.File, error) { 82 | fh, err := os.CreateTemp(dir, name) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to create temp file: %v", err) 85 | } 86 | defer fh.Close() 87 | if _, err := fh.Write(data); err != nil { 88 | return nil, err 89 | } 90 | return fh, nil 91 | } 92 | 93 | // GenerateCosignCertificateFiles generates sample signing keys for usage with cosign 94 | func GenerateCosignCertificateFiles(tmpDir string) (privFile, pubFile string, err error) { 95 | privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 96 | if err != nil { 97 | return "", "", err 98 | } 99 | encodedPub, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) 100 | if err != nil { 101 | return "", "", fmt.Errorf("failed to encode public key: %v", err) 102 | 103 | } 104 | encodedPriv, err := x509.MarshalPKCS8PrivateKey(privKey) 105 | if err != nil { 106 | return "", "", fmt.Errorf("failed to encode private key: %v", err) 107 | } 108 | 109 | password := []byte{} 110 | 111 | encryptedPrivBytes, err := encrypted.Encrypt(encodedPriv, password) 112 | if err != nil { 113 | return "", "", fmt.Errorf("failed to encrypt key: %v", err) 114 | } 115 | 116 | privKeyFile, err := writeTempFile(tmpDir, "cosign_test_*.key", pem.EncodeToMemory(&pem.Block{ 117 | Bytes: encryptedPrivBytes, 118 | Type: cosign.CosignPrivateKeyPemType, 119 | })) 120 | if err != nil { 121 | return "", "", fmt.Errorf("failed to create temp key file: %v", err) 122 | } 123 | 124 | pubKeyFile, err := writeTempFile(tmpDir, "cosign_test_*.pub", pem.EncodeToMemory(&pem.Block{ 125 | Bytes: encodedPub, 126 | Type: "PUBLIC KEY", 127 | })) 128 | 129 | if err != nil { 130 | return "", "", fmt.Errorf("failed to write pub key file: %v", err) 131 | } 132 | 133 | return privKeyFile.Name(), pubKeyFile.Name(), nil 134 | 135 | } 136 | -------------------------------------------------------------------------------- /cmd/dt/push_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http/httptest" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "testing" 13 | 14 | "github.com/google/go-containerregistry/pkg/crane" 15 | "github.com/google/go-containerregistry/pkg/registry" 16 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 17 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 18 | ) 19 | 20 | func (suite *CmdSuite) TestPushCommand() { 21 | t := suite.T() 22 | sb := suite.sb 23 | require := suite.Require() 24 | assert := suite.Assert() 25 | 26 | silentLog := log.New(io.Discard, "", 0) 27 | s := httptest.NewServer(registry.New(registry.Logger(silentLog))) 28 | defer s.Close() 29 | 30 | u, err := url.Parse(s.URL) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | serverURL := u.Host 35 | 36 | t.Run("Handle errors", func(t *testing.T) { 37 | t.Run("Handle missing Images.lock", func(t *testing.T) { 38 | scenarioName := "plain-chart" 39 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 40 | chartDir := sb.TempFile() 41 | 42 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 43 | map[string]interface{}{ 44 | "ServerURL": serverURL, "Images": nil, 45 | "Name": "test", "RepositoryURL": serverURL, 46 | }, 47 | )) 48 | dt("images", "push", chartDir).AssertErrorMatch(t, regexp.MustCompile(`failed to open Images.lock file:.*no such file or directory`)) 49 | }) 50 | t.Run("Handle malformed Helm chart", func(t *testing.T) { 51 | dt("images", "push", sb.TempFile()).AssertErrorMatch(t, regexp.MustCompile(`failed to load Helm chart`)) 52 | }) 53 | t.Run("Handle malformed Images.lock", func(t *testing.T) { 54 | scenarioName := "plain-chart" 55 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 56 | chartDir := sb.TempFile() 57 | 58 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 59 | map[string]interface{}{ 60 | "ServerURL": serverURL, "Images": nil, 61 | "Name": "test", "RepositoryURL": serverURL, 62 | }, 63 | )) 64 | require.NoError(os.WriteFile(filepath.Join(chartDir, imagelock.DefaultImagesLockFileName), []byte("malformed lock"), 0644)) 65 | dt("images", "push", chartDir).AssertErrorMatch(t, regexp.MustCompile(`failed to load Images.lock`)) 66 | }) 67 | t.Run("Handle failing to push images", func(t *testing.T) { 68 | scenarioName := "chart1" 69 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 70 | chartDir := sb.TempFile() 71 | 72 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 73 | map[string]interface{}{ 74 | "ServerURL": serverURL, "Images": nil, 75 | "Name": "test", "RepositoryURL": serverURL, 76 | }, 77 | )) 78 | dt("images", "push", chartDir).AssertErrorMatch(t, regexp.MustCompile(`(?i)failed to push images`)) 79 | }) 80 | }) 81 | t.Run("Pushing works", func(t *testing.T) { 82 | scenarioName := "complete-chart" 83 | 84 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 85 | 86 | imageData := tu.ImageData{Name: "test", Image: "test:mytag"} 87 | architectures := []string{ 88 | "linux/amd64", 89 | "linux/arm", 90 | } 91 | craneImgs, err := tu.CreateSampleImages(&imageData, architectures) 92 | 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | require.Equal(len(architectures), len(imageData.Digests)) 98 | 99 | images := []tu.ImageData{imageData} 100 | chartDir := sb.TempFile() 101 | 102 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 103 | map[string]interface{}{ 104 | "ServerURL": serverURL, "Images": images, 105 | "Name": "test", "RepositoryURL": serverURL, 106 | }, 107 | )) 108 | 109 | imagesDir := filepath.Join(chartDir, "images") 110 | require.NoError(os.MkdirAll(imagesDir, 0755)) 111 | for _, img := range craneImgs { 112 | d, digestErr := img.Digest() 113 | if digestErr != nil { 114 | t.Fatal(digestErr) 115 | } 116 | imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", d.Hex)) 117 | if ociErr := crane.SaveOCI(img, imgDir); ociErr != nil { 118 | t.Fatal(ociErr) 119 | } 120 | } 121 | 122 | t.Run("Push images", func(t *testing.T) { 123 | require.NoError(err) 124 | dt("images", "push", chartDir).AssertSuccessMatch(t, "") 125 | 126 | // Verify the images were pushed 127 | for _, img := range images { 128 | src := fmt.Sprintf("%s/%s", u.Host, img.Image) 129 | remoteDigests, err := tu.ReadRemoteImageManifest(src) 130 | if err != nil { 131 | t.Fatal(err) 132 | } 133 | for _, dgstData := range img.Digests { 134 | assert.Equal(dgstData.Digest.Hex(), remoteDigests[dgstData.Arch].Digest.Hex()) 135 | } 136 | } 137 | }) 138 | }) 139 | 140 | } 141 | -------------------------------------------------------------------------------- /CONTRIBUTING_CLA.md: -------------------------------------------------------------------------------- 1 | # Contributing to distribution-tooling-for-helm 2 | 3 | We welcome contributions from the community and first want to thank you for taking the time to contribute! 4 | 5 | Please familiarize yourself with the [Code of Conduct](https://github.com/vmware/.github/blob/main/CODE_OF_CONDUCT.md) before contributing. 6 | 7 | Before you start working with distribution-tooling-for-helm, please read and sign our Contributor License Agreement [CLA](https://cla.vmware.com/cla/1/preview). If you wish to contribute code and you have not signed our contributor license agreement (CLA), our bot will prompt you to do so when you open a Pull Request. For any questions about the CLA process, please refer to our [FAQ]([https://cla.vmware.com/faq](https://cla.vmware.com/faq)). 8 | 9 | ## Ways to contribute 10 | 11 | We welcome many different types of contributions and not all of them need a Pull request. Contributions may include: 12 | 13 | * New features and proposals 14 | * Documentation 15 | * Bug fixes 16 | * Issue Triage 17 | * Answering questions and giving feedback 18 | * Helping to onboard new contributors 19 | * Other related activities 20 | 21 | ## Getting started 22 | 23 | First of all make sure you have read our [README](README.md) and specifically the [installation, downloading and building from source](https://github.com/vmware-labs/distribution-tooling-for-helm/tree/main#installation) sections. 24 | 25 | For every contribution, you will have to make sure that all the tests pass. Moreover, consider adding new tests for any new functionality. You can run all the test by executing: 26 | 27 | ``` 28 | make test 29 | ``` 30 | 31 | Before sending any contribution is also a good practice to make sure that all code is formatted consistently: 32 | 33 | ``` 34 | make format 35 | ``` 36 | 37 | ## Contribution Flow 38 | 39 | This is a rough outline of what a contributor's workflow looks like: 40 | 41 | - Create a topic branch from where you want to base your work 42 | - Make commits of logical units 43 | - Make sure your commit messages are in the proper format (see below) 44 | - Push your changes to a topic branch in your fork of the repository 45 | - Submit a pull request 46 | 47 | Example: 48 | 49 | ``` shell 50 | git remote add upstream https://github.com/vmware-labs/distribution-tooling-for-helm.git 51 | git checkout -b my-new-feature main 52 | git commit -a 53 | git push origin my-new-feature 54 | ``` 55 | 56 | ### Staying In Sync With Upstream 57 | 58 | When your branch gets out of sync with the vmware-labs/main branch, use the following to update: 59 | 60 | ``` shell 61 | git checkout my-new-feature 62 | git fetch -a 63 | git pull --rebase upstream main 64 | git push --force-with-lease origin my-new-feature 65 | ``` 66 | 67 | ### Updating pull requests 68 | 69 | If your PR fails to pass CI or needs changes based on code review, you'll most likely want to squash these changes into 70 | existing commits. 71 | 72 | If your pull request contains a single commit or your changes are related to the most recent commit, you can simply 73 | amend the commit. 74 | 75 | ``` shell 76 | git add . 77 | git commit --amend 78 | git push --force-with-lease origin my-new-feature 79 | ``` 80 | 81 | If you need to squash changes into an earlier commit, you can use: 82 | 83 | ``` shell 84 | git add . 85 | git commit --fixup 86 | git rebase -i --autosquash main 87 | git push --force-with-lease origin my-new-feature 88 | ``` 89 | 90 | Be sure to add a comment to the PR indicating your new changes are ready to review, as GitHub does not generate a 91 | notification when you git push. 92 | 93 | ### Pull Request Checklist 94 | 95 | Before submitting your pull request, we advise you to use the following: 96 | 97 | 1. Check if your code changes will pass both code linting checks and unit tests. 98 | 2. Ensure your commit messages are descriptive. We follow the conventions on [How to Write a Git Commit Message](http://chris.beams.io/posts/git-commit/). Be sure to include any related GitHub issue references in the commit message. See [GFM syntax](https://guides.github.com/features/mastering-markdown/#GitHub-flavored-markdown) for referencing issues and commits. 99 | 3. Check the commits and commits messages and ensure they are free from typos. 100 | 101 | ## Release Process 102 | 103 | All stable code is hosted at the main branch. Releases are done on demand through the Release GitHub workflow. In order to release the current HEAD, you will need to trigger this workflow passing the version being released (i.e. v0.3.0). 104 | 105 | ## Reporting Bugs and Creating Issues 106 | 107 | For specifics on what to include in your report, please follow the guidelines in the issue and pull request templates when available. Try to roughly follow the commit message format conventions above. 108 | 109 | ## Ask for Help 110 | 111 | The best way to reach us with a question when contributing is by creating a new issue on the [GitHub issues](https://github.com/vmware-labs/distribution-tooling-for-helm/issues) section. 112 | -------------------------------------------------------------------------------- /install-binary.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | PROJECT_NAME="distribution-tooling-for-helm" 4 | BINARY_NAME="dt" 5 | PROJECT_GH="vmware-labs/$PROJECT_NAME" 6 | PLUGIN_MANIFEST="plugin.yaml" 7 | 8 | # Convert HELM_BIN and HELM_PLUGIN_DIR to unix if cygpath is 9 | # available. This is the case when using MSYS2 or Cygwin 10 | # on Windows where helm returns a Windows path but we 11 | # need a Unix path 12 | 13 | if command -v cygpath >/dev/null 2>&1; then 14 | HELM_BIN="$(cygpath -u "${HELM_BIN}")" 15 | HELM_PLUGIN_DIR="$(cygpath -u "${HELM_PLUGIN_DIR}")" 16 | fi 17 | 18 | [ -z "$HELM_BIN" ] && HELM_BIN=$(command -v helm) 19 | 20 | [ -z "$HELM_HOME" ] && HELM_HOME=$(helm env | grep 'HELM_DATA_HOME' | cut -d '=' -f2 | tr -d '"') 21 | 22 | mkdir -p "$HELM_HOME" 23 | 24 | if [ "$SKIP_BIN_INSTALL" = "1" ]; then 25 | echo "Skipping binary install" 26 | exit 27 | fi 28 | 29 | # which mode is the common installer script running in 30 | SCRIPT_MODE="install" 31 | if [ "$1" = "-u" ]; then 32 | SCRIPT_MODE="update" 33 | fi 34 | 35 | # initArch discovers the architecture for this system. 36 | initArch() { 37 | ARCH=$(uname -m) 38 | case $ARCH in 39 | armv5*) ARCH="armv5" ;; 40 | armv6*) ARCH="armv6" ;; 41 | armv7*) ARCH="armv7" ;; 42 | aarch64) ARCH="arm64" ;; 43 | x86) ARCH="386" ;; 44 | x86_64) ARCH="amd64" ;; 45 | i686) ARCH="386" ;; 46 | i386) ARCH="386" ;; 47 | esac 48 | } 49 | 50 | # initOS discovers the operating system for this system. 51 | initOS() { 52 | OS=$(uname -s) 53 | 54 | case "$OS" in 55 | Windows_NT) OS='windows' ;; 56 | # Msys support 57 | MSYS*) OS='windows' ;; 58 | # Minimalist GNU for Windows 59 | MINGW*) OS='windows' ;; 60 | CYGWIN*) OS='windows' ;; 61 | Darwin) OS='darwin' ;; 62 | Linux) OS='linux' ;; 63 | esac 64 | } 65 | 66 | # verifySupported checks that the os/arch combination is supported for 67 | # binary builds. 68 | verifySupported() { 69 | supported="linux-amd64\nlinux-arm64\nfreebsd-amd64\ndarwin-amd64\ndarwin-arm64\nwindows-amd64" 70 | if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then 71 | echo "No prebuild binary for ${OS}-${ARCH}." 72 | exit 1 73 | fi 74 | 75 | if 76 | ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1 77 | then 78 | echo "Either curl or wget is required" 79 | exit 1 80 | fi 81 | } 82 | 83 | # getDownloadURL checks the latest available version. 84 | getDownloadURL() { 85 | version="$(< "$HELM_PLUGIN_DIR/$PLUGIN_MANIFEST" grep "version" | cut -d '"' -f 2)" 86 | ext="tar.gz" 87 | if [ "$OS" = "windows" ]; then 88 | ext="zip" 89 | fi 90 | if [ "$SCRIPT_MODE" = "install" ] && [ -n "$version" ]; then 91 | DOWNLOAD_URL="https://github.com/${PROJECT_GH}/releases/download/v${version}/${PROJECT_NAME}_${version}_${OS}_${ARCH}.${ext}" 92 | else 93 | DOWNLOAD_URL="https://github.com/${PROJECT_GH}/releases/latest/download/${PROJECT_NAME}_${version}_${OS}_${ARCH}.${ext}" 94 | fi 95 | } 96 | 97 | # Temporary dir 98 | mkTempDir() { 99 | HELM_TMP="$(mktemp -d -t "${PROJECT_NAME}-XXXXXX")" 100 | } 101 | rmTempDir() { 102 | if [ -d "${HELM_TMP:-/tmp/distribution-tooling-for-helm-tmp}" ]; then 103 | rm -rf "${HELM_TMP:-/tmp/distribution-tooling-for-helm-tmp}" 104 | fi 105 | } 106 | 107 | # downloadFile downloads the latest binary package and also the checksum 108 | # for that binary. 109 | downloadFile() { 110 | PLUGIN_TMP_FILE="${HELM_TMP}/${PROJECT_NAME}.tgz" 111 | echo "Downloading $DOWNLOAD_URL" 112 | if 113 | command -v curl >/dev/null 2>&1 114 | then 115 | curl -sSf -L "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" 116 | elif 117 | command -v wget >/dev/null 2>&1 118 | then 119 | wget -q -O - "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" 120 | fi 121 | } 122 | 123 | # installFile verifies the SHA256 for the file, then unpacks and 124 | # installs it. 125 | installFile() { 126 | HELM_TMP_BIN="$HELM_TMP/$BINARY_NAME" 127 | if [ "${OS}" = "windows" ]; then 128 | HELM_TMP_BIN="$HELM_TMP_BIN.exe" 129 | unzip "$PLUGIN_TMP_FILE" -d "$HELM_TMP" 130 | else 131 | tar xzf "$PLUGIN_TMP_FILE" -C "$HELM_TMP" 132 | fi 133 | echo "Preparing to install into ${HELM_PLUGIN_DIR}" 134 | mkdir -p "$HELM_PLUGIN_DIR/bin" 135 | cp "$HELM_TMP_BIN" "$HELM_PLUGIN_DIR/bin" 136 | } 137 | 138 | # exit_trap is executed if on exit (error or not). 139 | exit_trap() { 140 | result=$? 141 | rmTempDir 142 | if [ "$result" != "0" ]; then 143 | echo "Failed to install $PROJECT_NAME" 144 | printf "\tFor support, go to https://github.com/%s.\n" "$PROJECT_GH" 145 | fi 146 | exit $result 147 | } 148 | 149 | # testVersion tests the installed client to make sure it is working. 150 | testVersion() { 151 | set +e 152 | echo "$PROJECT_NAME installed into $HELM_PLUGIN_DIR" 153 | "${HELM_PLUGIN_DIR}/bin/$BINARY_NAME" -h 154 | set -e 155 | } 156 | 157 | # Execution 158 | 159 | #Stop execution on any error 160 | trap "exit_trap" EXIT 161 | set -e 162 | initArch 163 | initOS 164 | verifySupported 165 | getDownloadURL 166 | mkTempDir 167 | downloadFile 168 | installFile 169 | testVersion 170 | -------------------------------------------------------------------------------- /pkg/carvel/carvel.go: -------------------------------------------------------------------------------- 1 | // Package carvel implements experimental Carvel support 2 | package carvel 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "strings" 8 | 9 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 11 | "github.com/vmware-tanzu/carvel-imgpkg/pkg/imgpkg/lockconfig" 12 | 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // CarvelBundleFilePath represents the usual bundle file for Carvel packaging 17 | const CarvelBundleFilePath = ".imgpkg/bundle.yml" 18 | 19 | // CarvelImagesFilePath represents the usual images file for Carvel packaging 20 | const CarvelImagesFilePath = ".imgpkg/images.yml" 21 | 22 | const carvelID = "kbld.carvel.dev/id" 23 | 24 | // Somehow there is no data structure for a bundle in Carvel. Copying some basics from the describe command. 25 | 26 | // Author information from a Bundle 27 | type Author struct { 28 | Name string `json:"name,omitempty"` 29 | Email string `json:"email,omitempty"` 30 | } 31 | 32 | // Website URL where more information of the Bundle can be found 33 | type Website struct { 34 | URL string `json:"url,omitempty"` 35 | } 36 | 37 | // Bundle Metadata 38 | const ( 39 | BundleAPIVersion = "imgpkg.carvel.dev/v1alpha1" 40 | BundleKind = "Bundle" 41 | ) 42 | 43 | // BundleVersion with detailsa bout the Carvel bundle version 44 | type BundleVersion struct { 45 | APIVersion string `json:"apiVersion"` // This generated yaml, but due to lib we need to use `json` 46 | Kind string `json:"kind"` // This generated yaml, but due to lib we need to use `json` 47 | } 48 | 49 | // Metadata for a Carvel bundle 50 | type Metadata struct { 51 | Version BundleVersion 52 | Metadata map[string]string `json:"metadata,omitempty"` 53 | Authors []Author `json:"authors,omitempty"` 54 | Websites []Website `json:"websites,omitempty"` 55 | } 56 | 57 | // ToYAML serializes the Carvel bundle into YAML 58 | func (il *Metadata) ToYAML(w io.Writer) error { 59 | enc := yaml.NewEncoder(w) 60 | enc.SetIndent(2) 61 | 62 | return enc.Encode(il) 63 | } 64 | 65 | // NewCarvelBundle returns a new carvel bundle Metadata instance 66 | func NewCarvelBundle() *Metadata { 67 | return &Metadata{ 68 | Version: BundleVersion{ 69 | APIVersion: BundleAPIVersion, 70 | Kind: BundleKind, 71 | }, 72 | Metadata: map[string]string{}, 73 | Authors: []Author{}, 74 | Websites: []Website{}, 75 | } 76 | } 77 | 78 | // CreateBundleMetadata builds and sets a new Carvel bundle struct 79 | func CreateBundleMetadata(chartPath string, lock *imagelock.ImagesLock, cfg *chartutils.Configuration) (*Metadata, error) { 80 | bundleMetadata := NewCarvelBundle() 81 | 82 | chart, err := chartutils.LoadChart(chartPath) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to load chart: %w", err) 85 | } 86 | 87 | for _, maintainer := range chart.Metadata().Maintainers { 88 | author := Author{ 89 | Name: maintainer.Name, 90 | } 91 | author.Email = maintainer.Email 92 | bundleMetadata.Authors = append(bundleMetadata.Authors, author) 93 | } 94 | for _, source := range chart.Metadata().Sources { 95 | website := Website{ 96 | URL: source, 97 | } 98 | bundleMetadata.Websites = append(bundleMetadata.Websites, website) 99 | } 100 | 101 | bundleMetadata.Metadata["name"] = lock.Chart.Name 102 | for key, value := range chart.Metadata().Annotations { 103 | annotationsKey := cfg.AnnotationsKey 104 | if annotationsKey == "" { 105 | annotationsKey = imagelock.DefaultAnnotationsKey 106 | } 107 | if key != annotationsKey { 108 | bundleMetadata.Metadata[key] = value 109 | } 110 | } 111 | return bundleMetadata, nil 112 | } 113 | 114 | // CreateImagesLock builds and set a new Carvel images lock struct 115 | func CreateImagesLock(lock *imagelock.ImagesLock) (lockconfig.ImagesLock, error) { 116 | imagesLock := lockconfig.ImagesLock{ 117 | LockVersion: lockconfig.LockVersion{ 118 | APIVersion: lockconfig.ImagesLockAPIVersion, 119 | Kind: lockconfig.ImagesLockKind, 120 | }, 121 | } 122 | for _, img := range lock.Images { 123 | // Carvel does not seem to support multi-arch. Grab amd64 digest 124 | 125 | name := img.Image 126 | i := strings.LastIndex(img.Image, ":") 127 | if i > -1 { 128 | name = img.Image[0:i] 129 | } 130 | //TODO: Clarify with Carvel community their multi-arch support 131 | //for the time being we stick to amd64 132 | imageWithDigest := getIntelImageWithDigest(name, img) 133 | if imageWithDigest == "" { 134 | // See above. Skip 135 | break 136 | } 137 | imageRef := lockconfig.ImageRef{ 138 | Image: imageWithDigest, 139 | Annotations: map[string]string{ 140 | carvelID: img.Image, 141 | }, 142 | } 143 | imagesLock.AddImageRef(imageRef) 144 | } 145 | return imagesLock, nil 146 | } 147 | 148 | func getIntelImageWithDigest(name string, img *imagelock.ChartImage) string { 149 | 150 | for _, digest := range img.Digests { 151 | if digest.Arch == "linux/amd64" { 152 | return fmt.Sprintf("%s@%s", name, digest.Digest.String()) 153 | } 154 | } 155 | return "" 156 | } 157 | -------------------------------------------------------------------------------- /pkg/chartutils/images_test.go: -------------------------------------------------------------------------------- 1 | package chartutils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net/http/httptest" 8 | "net/url" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | 13 | "github.com/google/go-containerregistry/pkg/crane" 14 | "github.com/google/go-containerregistry/pkg/registry" 15 | tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" 16 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 17 | ) 18 | 19 | func (suite *ChartUtilsTestSuite) TestPullImages() { 20 | require := suite.Require() 21 | t := suite.T() 22 | 23 | silentLog := log.New(io.Discard, "", 0) 24 | s := httptest.NewServer(registry.New(registry.Logger(silentLog))) 25 | defer s.Close() 26 | 27 | u, err := url.Parse(s.URL) 28 | if err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | imageName := "test:mytag" 33 | 34 | images, err := tu.AddSampleImagesToRegistry(imageName, u.Host) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | sb := suite.sb 40 | 41 | serverURL := u.Host 42 | scenarioName := "complete-chart" 43 | chartName := "test" 44 | 45 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 46 | 47 | t.Run("Pulls images", func(_ *testing.T) { 48 | chartDir := sb.TempFile() 49 | 50 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 51 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, 52 | )) 53 | imagesDir := filepath.Join(chartDir, "images") 54 | 55 | lock, tErr := imagelock.FromYAMLFile(filepath.Join(chartDir, "Images.lock")) 56 | require.NoError(tErr) 57 | require.NoError(PullImages(lock, imagesDir)) 58 | 59 | require.DirExists(imagesDir) 60 | 61 | for _, imgData := range images { 62 | for _, digestData := range imgData.Digests { 63 | imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", digestData.Digest.Encoded())) 64 | suite.Assert().DirExists(imgDir) 65 | } 66 | } 67 | }) 68 | 69 | t.Run("Error when no images in Images.lock", func(_ *testing.T) { 70 | chartDir := sb.TempFile() 71 | 72 | images := []tu.ImageData{} 73 | scenarioName := "no-images-chart" 74 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 75 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 76 | map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, 77 | )) 78 | imagesDir := filepath.Join(chartDir, "images") 79 | 80 | lock, tErr := imagelock.FromYAMLFile(filepath.Join(chartDir, "Images.lock")) 81 | require.NoError(tErr) 82 | require.Error(PullImages(lock, imagesDir)) 83 | 84 | require.DirExists(imagesDir) 85 | 86 | for _, imgData := range images { 87 | for _, digestData := range imgData.Digests { 88 | imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", digestData.Digest.Encoded())) 89 | suite.Assert().DirExists(imgDir) 90 | } 91 | } 92 | }) 93 | } 94 | 95 | func (suite *ChartUtilsTestSuite) TestPushImages() { 96 | 97 | t := suite.T() 98 | sb := suite.sb 99 | require := suite.Require() 100 | assert := suite.Assert() 101 | 102 | silentLog := log.New(io.Discard, "", 0) 103 | s := httptest.NewServer(registry.New(registry.Logger(silentLog))) 104 | defer s.Close() 105 | 106 | u, err := url.Parse(s.URL) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | serverURL := u.Host 111 | 112 | t.Run("Pushing works", func(t *testing.T) { 113 | scenarioName := "complete-chart" 114 | chartName := "test" 115 | 116 | scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) 117 | 118 | imageData := tu.ImageData{Name: "test", Image: "test:mytag"} 119 | architectures := []string{ 120 | "linux/amd64", 121 | "linux/arm", 122 | } 123 | 124 | craneImgs, err := tu.CreateSampleImages(&imageData, architectures) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | 129 | require.Equal(len(architectures), len(imageData.Digests)) 130 | 131 | images := []tu.ImageData{imageData} 132 | chartDir := sb.TempFile() 133 | 134 | require.NoError(tu.RenderScenario(scenarioDir, chartDir, 135 | map[string]interface{}{ 136 | "ServerURL": serverURL, "Images": images, 137 | "Name": chartName, "RepositoryURL": serverURL, 138 | }, 139 | )) 140 | 141 | imagesDir := filepath.Join(chartDir, "images") 142 | require.NoError(os.MkdirAll(imagesDir, 0755)) 143 | for _, img := range craneImgs { 144 | d, tErr := img.Digest() 145 | if tErr != nil { 146 | t.Fatal(tErr) 147 | } 148 | 149 | imgFile := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", d.Hex)) 150 | if tErr = crane.SaveOCI(img, imgFile); tErr != nil { 151 | t.Fatal(tErr) 152 | } 153 | } 154 | 155 | t.Run("Push images", func(t *testing.T) { 156 | lock, err := imagelock.FromYAMLFile(filepath.Join(chartDir, "Images.lock")) 157 | require.NoError(err) 158 | require.NoError(PushImages(lock, imagesDir)) 159 | 160 | // Verify the images were pushed 161 | for _, img := range images { 162 | src := fmt.Sprintf("%s/%s", u.Host, img.Image) 163 | remoteDigests, err := tu.ReadRemoteImageManifest(src) 164 | if err != nil { 165 | t.Fatal(err) 166 | } 167 | for _, dgstData := range img.Digests { 168 | assert.Equal(dgstData.Digest.Hex(), remoteDigests[dgstData.Arch].Digest.Hex()) 169 | } 170 | } 171 | }) 172 | }) 173 | } 174 | -------------------------------------------------------------------------------- /cmd/dt/carvelize/carvelize.go: -------------------------------------------------------------------------------- 1 | // Package carvelize provides the carvelize command 2 | package carvelize 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" 12 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/lock" 13 | "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/verify" 14 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/carvel" 15 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" 16 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 17 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" 18 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" 19 | 20 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 21 | ) 22 | 23 | // NewCmd builds a new carvelize command 24 | func NewCmd(cfg *config.Config) *cobra.Command { 25 | var yamlFormat bool 26 | var showDetails bool 27 | 28 | cmd := &cobra.Command{ 29 | Use: "carvelize FILE", 30 | Short: "Adds a Carvel bundle to the Helm chart (Experimental)", 31 | Long: `Experimental. Adds a Carvel bundle to an existing Helm chart`, 32 | Example: ` # Adds a Carvel bundle to a Helm chart 33 | $ dt charts carvelize examples/mariadb`, 34 | SilenceUsage: true, 35 | SilenceErrors: true, 36 | Args: cobra.ExactArgs(1), 37 | RunE: func(_ *cobra.Command, args []string) error { 38 | chartPath := args[0] 39 | l := cfg.Logger() 40 | // Allows silencing called methods 41 | silentLog := silent.NewLogger() 42 | 43 | lockFile, err := chartutils.GetImageLockFilePath(chartPath) 44 | if err != nil { 45 | return fmt.Errorf("failed to determine Images.lock file location: %w", err) 46 | } 47 | 48 | if utils.FileExists(lockFile) { 49 | if err := l.ExecuteStep("Verifying Images.lock", func() error { 50 | return verify.Lock(chartPath, lockFile, verify.Config{Insecure: cfg.Insecure, AnnotationsKey: cfg.AnnotationsKey}) 51 | }); err != nil { 52 | return l.Failf("Failed to verify lock: %w", err) 53 | } 54 | l.Infof("Helm chart %q lock is valid", chartPath) 55 | 56 | } else { 57 | err := l.ExecuteStep( 58 | "Images.lock file does not exist. Generating it from annotations...", 59 | func() error { 60 | return lock.Create(chartPath, 61 | lockFile, silentLog, imagelock.WithAnnotationsKey(cfg.AnnotationsKey), imagelock.WithInsecure(cfg.Insecure), 62 | ) 63 | }, 64 | ) 65 | if err != nil { 66 | return l.Failf("Failed to generate lock: %w", err) 67 | } 68 | l.Infof("Images.lock file written to %q", lockFile) 69 | } 70 | if err := l.Section(fmt.Sprintf("Generating Carvel bundle for Helm chart %q", chartPath), func(childLog log.SectionLogger) error { 71 | if err := GenerateBundle( 72 | chartPath, 73 | chartutils.WithAnnotationsKey(cfg.AnnotationsKey), 74 | chartutils.WithLog(childLog), 75 | ); err != nil { 76 | return childLog.Failf("%v", err) 77 | } 78 | return nil 79 | }); err != nil { 80 | return l.Failf("%w", err) 81 | } 82 | l.Successf("Carvel bundle created successfully") 83 | return nil 84 | }, 85 | } 86 | cmd.PersistentFlags().BoolVar(&yamlFormat, "yaml", yamlFormat, "Show report in YAML format") 87 | cmd.PersistentFlags().BoolVar(&showDetails, "detailed", showDetails, "When using the printable report, add more details about the bundled images") 88 | 89 | return cmd 90 | } 91 | 92 | // GenerateBundle generates a Carvel bundle for a Helm chart 93 | func GenerateBundle(chartPath string, opts ...chartutils.Option) error { 94 | cfg := chartutils.NewConfiguration(opts...) 95 | l := cfg.Log 96 | 97 | lock, err := chartutils.ReadLockFromChart(chartPath) 98 | if err != nil { 99 | return fmt.Errorf("failed to load Images.lock: %v", err) 100 | } 101 | 102 | imgPkgPath := filepath.Join(chartPath, ".imgpkg") 103 | if !utils.FileExists(imgPkgPath) { 104 | if err = os.Mkdir(imgPkgPath, os.FileMode(0755)); err != nil { 105 | return fmt.Errorf("failed to create .imgpkg directory: %w", err) 106 | } 107 | } 108 | 109 | bundleMetadata, err := carvel.CreateBundleMetadata(chartPath, lock, cfg) 110 | if err != nil { 111 | return fmt.Errorf("failed to prepare Carvel bundle: %w", err) 112 | } 113 | 114 | carvelImagesLock, err := carvel.CreateImagesLock(lock) 115 | if err != nil { 116 | return fmt.Errorf("failed to prepare Carvel images lock: %w", err) 117 | } 118 | l.Infof("Validating Carvel images lock") 119 | 120 | err = carvelImagesLock.Validate() 121 | if err != nil { 122 | return fmt.Errorf("failed to validate Carvel images lock: %w", err) 123 | } 124 | 125 | path := filepath.Join(imgPkgPath, "images.yml") 126 | err = carvelImagesLock.WriteToPath(path) 127 | if err != nil { 128 | return fmt.Errorf("could not write image lock: %w", err) 129 | } 130 | l.Infof("Carvel images lock written to %q", path) 131 | 132 | buff := &bytes.Buffer{} 133 | if err = bundleMetadata.ToYAML(buff); err != nil { 134 | return fmt.Errorf("failed to write bundle metadata file: %v", err) 135 | } 136 | 137 | path = imgPkgPath + "/bundle.yml" 138 | if err := os.WriteFile(path, buff.Bytes(), 0666); err != nil { 139 | return fmt.Errorf("failed to write Carvel bundle metadata to %q: %w", path, err) 140 | } 141 | l.Infof("Carvel metadata written to %q", path) 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | // Package utils implements helper functions 2 | package utils 3 | 4 | import ( 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "strings" 13 | 14 | "github.com/google/go-containerregistry/pkg/name" 15 | "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath" 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | // FileExists checks if filename exists 20 | func FileExists(filename string) bool { 21 | _, err := os.Stat(filename) 22 | return err == nil 23 | } 24 | 25 | func rawYamlSet(n *yaml.Node, path string, value string) error { 26 | p, err := yamlpath.NewPath(path) 27 | 28 | if err != nil { 29 | return fmt.Errorf("cannot create YAML path: %v", err) 30 | } 31 | q, err := p.Find(n) 32 | if err != nil { 33 | return fmt.Errorf("cannot find YAML path %q: %v", path, err) 34 | } 35 | if len(q) == 0 { 36 | return fmt.Errorf("cannot find YAML path %q", path) 37 | } 38 | if len(q) > 1 { 39 | return fmt.Errorf("expected single result replacing image but found %d", len(q)) 40 | } 41 | yamlElement := q[0] 42 | 43 | yamlElement.Value = value 44 | return nil 45 | 46 | } 47 | 48 | // YamlFileSet sets the list of key-value specified in values in the YAML file. 49 | // The keys are in jsonpath format 50 | func YamlFileSet(file string, values map[string]string) error { 51 | data, err := os.ReadFile(file) 52 | if err != nil { 53 | return fmt.Errorf("failed to set YAML file %q: %v", file, err) 54 | } 55 | data, err = YamlSet(data, values) 56 | if err != nil { 57 | return fmt.Errorf("failed to set YAML file %q: %v", file, err) 58 | } 59 | return SafeWriteFile(file, data, 0644) 60 | } 61 | 62 | // YamlSet sets the list of key-value specified in values in the YAML data. 63 | // The keys are in jsonpath format 64 | func YamlSet(data []byte, values map[string]string) ([]byte, error) { 65 | var allErrors error 66 | var n yaml.Node 67 | 68 | if err := yaml.Unmarshal(data, &n); err != nil { 69 | return nil, fmt.Errorf("cannot unmarshal YAML data: %v", err) 70 | } 71 | for path, value := range values { 72 | if err := rawYamlSet(&n, path, value); err != nil { 73 | allErrors = errors.Join(allErrors, err) 74 | } 75 | } 76 | if allErrors != nil { 77 | return nil, allErrors 78 | } 79 | 80 | var buf bytes.Buffer 81 | e := yaml.NewEncoder(&buf) 82 | e.SetIndent(2) 83 | 84 | if err := e.Encode(&n); err != nil { 85 | return nil, fmt.Errorf("failed to format YAML: %v", err) 86 | } 87 | if err := e.Close(); err != nil { 88 | return nil, fmt.Errorf("failed to finalize YAML: %v", err) 89 | } 90 | return buf.Bytes(), nil 91 | } 92 | 93 | // SafeWriteFile writes data into the specified filename by first creating it, and then renaming 94 | // to the final destination to minimize breaking the file 95 | func SafeWriteFile(filename string, data []byte, perm os.FileMode) error { 96 | 97 | f, err := os.CreateTemp(filepath.Dir(filename), "tmp") 98 | if err != nil { 99 | return err 100 | } 101 | err = f.Chmod(perm) 102 | if err != nil { 103 | return err 104 | } 105 | tmpname := f.Name() 106 | 107 | // write data to temp file 108 | n, err := f.Write(data) 109 | if err == nil && n < len(data) { 110 | err = io.ErrShortWrite 111 | } 112 | if err1 := f.Close(); err == nil { 113 | err = err1 114 | } 115 | if err != nil { 116 | return err 117 | } 118 | 119 | return os.Rename(tmpname, filename) 120 | } 121 | 122 | // RelocateImageURL rewrites the provided image url by replacing its prefix 123 | func RelocateImageURL(url string, prefix string, includeIndentifier bool) (string, error) { 124 | ref, err := name.ParseReference(url) 125 | if err != nil { 126 | return "", fmt.Errorf("failed to relocate url: %v", err) 127 | } 128 | normalizedURL := ref.Context().Name() 129 | 130 | // We will preserve the last past of the repository 131 | re := regexp.MustCompile("^.*?/(([^/]+/)?[^/]+)$") 132 | match := re.FindStringSubmatch(normalizedURL) 133 | if match == nil { 134 | return "", fmt.Errorf("failed to parse normalized URL") 135 | } 136 | newURL := fmt.Sprintf("%s/%s", strings.TrimRight(prefix, "/"), match[1]) 137 | if includeIndentifier && ref.Identifier() != "" { 138 | separator := ":" 139 | if _, ok := ref.(name.Digest); ok { 140 | separator = "@" 141 | } 142 | newURL = fmt.Sprintf("%s%s%s", newURL, separator, ref.Identifier()) 143 | } 144 | return newURL, nil 145 | } 146 | 147 | // ExecuteWithRetry executes a function retrying until it succeeds or the number of retries is reached 148 | func ExecuteWithRetry(retries int, cb func(try int, prevErr error) error) error { 149 | retry := 0 150 | var err error 151 | for { 152 | err = cb(retry, err) 153 | if err == nil { 154 | break 155 | } 156 | if retry < retries { 157 | retry++ 158 | continue 159 | } 160 | return err 161 | } 162 | return nil 163 | } 164 | 165 | // TruncateStringWithEllipsis returns a truncated version of text 166 | func TruncateStringWithEllipsis(text string, maxLength int) string { 167 | if len(text) <= maxLength { 168 | return text 169 | } 170 | if maxLength <= 0 { 171 | return "" 172 | } 173 | 174 | ellipsis := "[...]" 175 | 176 | // If the maxLength is so small the ellipsis does not fit, just return the prefix 177 | if maxLength <= len(ellipsis) { 178 | return text[0:maxLength] 179 | } 180 | startSplit := (maxLength - len(ellipsis)) / 2 181 | endSplit := len(text) - (maxLength - startSplit - len(ellipsis)) 182 | return text[0:startSplit] + ellipsis + text[endSplit:] 183 | } 184 | -------------------------------------------------------------------------------- /pkg/chartutils/chart.go: -------------------------------------------------------------------------------- 1 | // Package chartutils implements helper functions to manipulate helm Charts 2 | package chartutils 3 | 4 | import ( 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" 9 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" 10 | "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" 11 | 12 | "helm.sh/helm/v3/pkg/chart" 13 | "helm.sh/helm/v3/pkg/chart/loader" 14 | ) 15 | 16 | // Chart defines a helm Chart with extra functionalities 17 | type Chart struct { 18 | chart *chart.Chart 19 | rootDir string 20 | annotationsKey string 21 | valuesFiles []string 22 | } 23 | 24 | // ChartFullPath returns the wrapped chart ChartFullPath 25 | func (c *Chart) ChartFullPath() string { 26 | return c.chart.ChartFullPath() 27 | } 28 | 29 | // Name returns the name of the chart 30 | func (c *Chart) Name() string { 31 | return c.chart.Name() 32 | } 33 | 34 | // Version returns the version of the chart 35 | func (c *Chart) Version() string { 36 | return c.chart.Metadata.Version 37 | } 38 | 39 | // Metadata returns the metadata of the chart 40 | func (c *Chart) Metadata() *chart.Metadata { 41 | return c.chart.Metadata 42 | } 43 | 44 | // RootDir returns the Chart root directory 45 | func (c *Chart) RootDir() string { 46 | return c.rootDir 47 | } 48 | 49 | // ChartDir returns the Chart root directory (required to implement wrapping.Unwrapable) 50 | func (c *Chart) ChartDir() string { 51 | return c.RootDir() 52 | } 53 | 54 | // VerifyLock verifies the Images.lock file for the chart 55 | func (c *Chart) VerifyLock(opts ...imagelock.Option) error { 56 | chartPath := c.ChartDir() 57 | if !utils.FileExists(chartPath) { 58 | return fmt.Errorf("chart %q does not exist", chartPath) 59 | } 60 | 61 | currentLock, err := c.GetImagesLock() 62 | if err != nil { 63 | return fmt.Errorf("failed to load Images.lock: %w", err) 64 | } 65 | calculatedLock, err := imagelock.GenerateFromChart(chartPath, 66 | opts..., 67 | ) 68 | 69 | if err != nil { 70 | return fmt.Errorf("failed to re-create Images.lock from Helm chart %q: %v", chartPath, err) 71 | } 72 | 73 | if err := calculatedLock.Validate(currentLock.Images); err != nil { 74 | return fmt.Errorf("validation failed for Images.lock:\n%v", err) 75 | } 76 | return nil 77 | } 78 | 79 | // Chart returns the Chart object (required to implement wrapping.Unwrapable) 80 | func (c *Chart) Chart() *Chart { 81 | return c 82 | } 83 | 84 | // LockFilePath returns the absolute path to the chart Images.lock 85 | func (c *Chart) LockFilePath() string { 86 | return c.AbsFilePath(imagelock.DefaultImagesLockFileName) 87 | } 88 | 89 | // GetImagesLock returns the chart's ImagesLock object 90 | func (c *Chart) GetImagesLock() (*imagelock.ImagesLock, error) { 91 | lockFile := c.LockFilePath() 92 | 93 | lock, err := imagelock.FromYAMLFile(lockFile) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return lock, nil 99 | } 100 | 101 | // ImageArtifactsDir returns the imags artifacts directory 102 | func (c *Chart) ImageArtifactsDir() string { 103 | return filepath.Join(c.RootDir(), artifacts.HelmArtifactsFolder, "images") 104 | } 105 | 106 | // ImagesDir returns the images directory inside the chart root directory 107 | func (c *Chart) ImagesDir() string { 108 | return filepath.Join(c.RootDir(), "images") 109 | } 110 | 111 | // File returns the chart.File for the provided name or nil if not found 112 | func (c *Chart) File(name string) *chart.File { 113 | return getChartFile(c.chart, name) 114 | } 115 | 116 | // ValuesFiles returns all the values chart.File 117 | func (c *Chart) ValuesFiles() []*chart.File { 118 | files := make([]*chart.File, 0, len(c.valuesFiles)) 119 | for _, valuesFile := range c.valuesFiles { 120 | files = append(files, c.File(valuesFile)) 121 | } 122 | return files 123 | } 124 | 125 | // AbsFilePath returns the absolute path to the Chart relative file name 126 | func (c *Chart) AbsFilePath(name string) string { 127 | return filepath.Join(c.rootDir, name) 128 | } 129 | 130 | // GetAnnotatedImages returns the chart images specified in the annotations 131 | func (c *Chart) GetAnnotatedImages() (imagelock.ImageList, error) { 132 | return imagelock.GetImagesFromChartAnnotations( 133 | c.chart, 134 | imagelock.NewImagesLockConfig( 135 | imagelock.WithAnnotationsKey(c.annotationsKey), 136 | ), 137 | ) 138 | } 139 | 140 | // Dependencies returns the chart dependencies 141 | func (c *Chart) Dependencies() []*Chart { 142 | cfg := NewConfiguration(WithAnnotationsKey(c.annotationsKey), WithValuesFiles(c.valuesFiles...)) 143 | deps := make([]*Chart, 0) 144 | 145 | for _, dep := range c.chart.Dependencies() { 146 | subChart := filepath.Join(c.RootDir(), "charts", dep.Name()) 147 | deps = append(deps, newChart(dep, subChart, cfg)) 148 | } 149 | return deps 150 | } 151 | 152 | // LoadChart returns the Chart defined by path 153 | func LoadChart(path string, opts ...Option) (*Chart, error) { 154 | cfg := NewConfiguration(opts...) 155 | 156 | chart, err := loader.Load(path) 157 | if err != nil { 158 | return nil, fmt.Errorf("failed to load Helm chart: %v", err) 159 | } 160 | chartRoot, err := GetChartRoot(path) 161 | if err != nil { 162 | return nil, fmt.Errorf("cannot determine Helm chart root: %v", err) 163 | } 164 | return newChart(chart, chartRoot, cfg), nil 165 | } 166 | 167 | func newChart(c *chart.Chart, chartRoot string, cfg *Configuration) *Chart { 168 | return &Chart{ 169 | chart: c, 170 | rootDir: chartRoot, 171 | annotationsKey: cfg.AnnotationsKey, 172 | valuesFiles: cfg.ValuesFiles, 173 | } 174 | } 175 | --------------------------------------------------------------------------------