diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..559ad26 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,53 @@ +name: CI +on: + push: + branches: + - main + pull_request: + branches: + - main + types: + - assigned + - opened + - synchronize + - reopened + +jobs: + Validate: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3 + with: + fetch-depth: 0 + + - name: Set Helm + uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 + with: + version: v3.12.1 + + - name: Set Golang + uses: actions/setup-go@v4 + with: + go-version: 1.21.9 + + - name: Set Golangci-lint + run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 + + - name: Set Shellcheck + run: sudo apt-get -qq update && sudo apt-get install -y shellcheck && shellcheck install-binary.sh + + - name: Build + run: make build + + - name: Test + run: make test + + - name: Install + run: make install + + - name: Check Binary + run: ./bin/dt + + - name: Check Helm Plugin + run: helm dt diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000..a9b5319 --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -0,0 +1,39 @@ +name: Prepare release +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag (i.e. v1.2.3)' + required: true + type: string + +jobs: + Prepare: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3 + with: + fetch-depth: 0 + + - name: Config Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Fetch Version + run: echo PLUGIN_VERSION=$(echo "${{ inputs.tag }}" | tr -d 'v') >> "$GITHUB_ENV" + + - name: Update Version + run: | + sed -i "s/version: \".*\"/version: \"$PLUGIN_VERSION\"/" plugin.yaml + sed -i "s/var Version = \".*\"/var Version = \"$PLUGIN_VERSION\"/" cmd/dt/version.go + git checkout -B release/$PLUGIN_VERSION + git add plugin.yaml cmd/dt/version.go + git commit -m 'Prepare release ${{ inputs.tag }}' + git push origin release/$PLUGIN_VERSION + + - name: Create PR + run: gh pr create --fill --base main --repo $GITHUB_REPOSITORY + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..019707e --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,53 @@ +name: Release +on: + workflow_run: + workflows: + - CI + types: + - completed + branches: + - main + +permissions: + contents: write + +jobs: + Release: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && contains(github.event.workflow_run.head_commit.message, 'Prepare release v') }} + steps: + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3 + with: + fetch-depth: 0 + + - name: Fetch Version + run: | + PLUGIN_VERSION=v$(cat plugin.yaml | grep "version" | cut -d '"' -f 2) + LATEST_VERSION=$(git describe --tags --abbrev=0) + echo PLUGIN_VERSION=$PLUGIN_VERSION >> "$GITHUB_ENV" + echo LATEST_VERSION=$LATEST_VERSION >> "$GITHUB_ENV" + + - name: Check Version + if: ${{ env.PLUGIN_VERSION == env.LATEST_VERSION }} + run: echo "Plugin version already released. Please make sure you have prepared the release first." && exit 1 + + - name: Set Golang + uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + with: + go-version: 1.21.9 + + - name: Build + run: make build + + - name: Create tag + run: git tag $PLUGIN_VERSION + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@336e29918d653399e599bfca99fadc1d7ffbc9f7 # v4.3.0 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5e7d273..984714a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore +/bin/ +/dist/ +/examples/ +**~ +**.tgz +/out/ +**/#* +**/.#* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..03b2013 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,68 @@ +linters: + enable: + - bodyclose + - dogsled + - gocyclo + - gofmt + - goimports + - gosec + - gosimple + - govet + - ineffassign + - lll + - megacheck + - misspell + - nakedret + - revive + - staticcheck + - typecheck + - unconvert + - unused + + disable: + - errcheck + +run: + timeout: 5m + +linters-settings: + gocyclo: + min-complexity: 18 + govet: + check-shadowing: false + lll: + line-length: 200 + nakedret: + command: nakedret + pattern: ^(?P.*?\\.go):(?P\\d+)\\s*(?P.*)$ + +issues: + # The default exclusion rules are a bit too permissive, so copying the relevant ones below + exclude-use-default: false + + exclude: + - parameter .* always receives + + exclude-rules: + # EXC0009 + - text: "(Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less)" + linters: + - gosec + # EXC0010 + - text: "Potential file inclusion via variable" + linters: + - gosec + - path: test # Excludes /test, *_test.go etc. + linters: + - gosec + # Looks like the match in "EXC0009" above doesn't catch this one + # TODO: consider upstreaming this to golangci-lint's default exclusion rules + - text: "G306: Expect WriteFile permissions to be 0600 or less" + linters: + - gosec + + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-issues-per-linter: 0 + + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..8b6f80d --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,34 @@ +release: + target_commitish: '{{ .Commit }}' +builds: + - id: dt + binary: dt + main: ./cmd/dt + env: + - CGO_ENABLED=0 + targets: + - darwin_amd64 + - darwin_arm64 + - linux_amd64 + - linux_arm64 + - linux_arm + - windows_amd64 + mod_timestamp: "{{ .CommitTimestamp }}" + ldflags: + - >- + -X main.Version={{ .Tag }} + -X main.GitCommit={{ .Commit }} + -X main.BuildDate={{ .Date }} +archives: + - builds: + - dt + format_overrides: + - goos: windows + format: zip +checksum: + algorithm: sha256 +changelog: + sort: asc + filters: + exclude: + - '^docs:' diff --git a/CONTRIBUTING_CLA.md b/CONTRIBUTING_CLA.md index 6436d34..80eb3ea 100644 --- a/CONTRIBUTING_CLA.md +++ b/CONTRIBUTING_CLA.md @@ -1,7 +1,5 @@ # Contributing to distribution-tooling-for-helm -_NOTE: This is a template document that requires editing before it is ready to use!_ - We welcome contributions from the community and first want to thank you for taking the time to contribute! Please familiarize yourself with the [Code of Conduct](https://github.com/vmware/.github/blob/main/CODE_OF_CONDUCT.md) before contributing. @@ -22,54 +20,92 @@ We welcome many different types of contributions and not all of them need a Pull ## Getting started -_TO BE EDITED: This section explains how to build the project from source, including Development Environment Setup, Build, Run and Test._ +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. + +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: -_Provide information about how someone can find your project, get set up, build the code, test it, and submit a pull request successfully without having to ask any questions. Also include common errors people run into, or useful scripts they should run._ +``` +make test +``` -_List any tests that the contributor should run / or testing processes to follow before submitting. Describe any automated and manual checks performed by reviewers._ +Before sending any contribution is also a good practice to make sure that all code is formatted consistently: +``` +make format +``` ## Contribution Flow This is a rough outline of what a contributor's workflow looks like: -* Make a fork of the repository within your GitHub account -* Create a topic branch in your fork from where you want to base your work -* Make commits of logical units -* Make sure your commit messages are with the proper format, quality and descriptiveness (see below) -* Push your changes to the topic branch in your fork -* Create a pull request containing that commit +- Create a topic branch from where you want to base your work +- Make commits of logical units +- Make sure your commit messages are in the proper format (see below) +- Push your changes to a topic branch in your fork of the repository +- Submit a pull request -We follow the GitHub workflow and you can find more details on the [GitHub flow documentation](https://docs.github.com/en/get-started/quickstart/github-flow). +Example: +``` shell +git remote add upstream https://github.com/vmware-labs/distribution-tooling-for-helm.git +git checkout -b my-new-feature main +git commit -a +git push origin my-new-feature +``` -### Pull Request Checklist +### Staying In Sync With Upstream -Before submitting your pull request, we advise you to use the following: +When your branch gets out of sync with the vmware-labs/main branch, use the following to update: -1. Check if your code changes will pass both code linting checks and unit tests. -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. -3. Check the commits and commits messages and ensure they are free from typos. +``` shell +git checkout my-new-feature +git fetch -a +git pull --rebase upstream main +git push --force-with-lease origin my-new-feature +``` -## Reporting Bugs and Creating Issues +### Updating pull requests -For specifics on what to include in your report, please follow the guidelines in the issue and pull request templates when available. +If your PR fails to pass CI or needs changes based on code review, you'll most likely want to squash these changes into +existing commits. -_TO BE EDITED: Add additional information if needed._ +If your pull request contains a single commit or your changes are related to the most recent commit, you can simply +amend the commit. +``` shell +git add . +git commit --amend +git push --force-with-lease origin my-new-feature +``` -## Ask for Help +If you need to squash changes into an earlier commit, you can use: + +``` shell +git add . +git commit --fixup +git rebase -i --autosquash main +git push --force-with-lease origin my-new-feature +``` -_TO BE EDITED: Provide information about the channels you use to communicate (i.e. Slack, IRC, Discord, etc)_ +Be sure to add a comment to the PR indicating your new changes are ready to review, as GitHub does not generate a +notification when you git push. -The best way to reach us with a question when contributing is to ask on: +### Pull Request Checklist + +Before submitting your pull request, we advise you to use the following: + +1. Check if your code changes will pass both code linting checks and unit tests. +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. +3. Check the commits and commits messages and ensure they are free from typos. -* The original GitHub issue -* The developer mailing list -* Our Slack channel +## Release Process +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). -## Additional Resources +## Reporting Bugs and Creating Issues -_Optional_ +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. + +## Ask for Help +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f2d892 --- /dev/null +++ b/Makefile @@ -0,0 +1,120 @@ +# Required for globs to work correctly +SHELL = /usr/bin/env bash + +BINDIR := $(CURDIR)/bin +TOOL := dt +BINNAME ?= $(TOOL) + +PROJECT_PLUGIN_SHORTNAME := helm-dt + +GOPATH ?= $(shell go env GOPATH) +PATH := $(GOPATH)/bin:$(PATH) + +BUILD_DIR := $(abspath ./out) + +PKG := github.com/vmware-labs/distribution-tooling-for-helm + +VERSION := $(shell sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' plugin.yaml) + + +# Rebuild the binary if any of these files change +SRC := $(shell find . -type f -name '*.go' -print) go.mod go.sum + +GOBIN = $(shell go env GOBIN) +ifeq ($(GOBIN),) +GOBIN = $(shell go env GOPATH)/bin +endif + +GOIMPORTS = $(GOBIN)/goimports +GOLANGCILINT = $(GOBIN)/golangci-lint + +ARCH = $(shell uname -p) + +TAGS := +TESTS := . +TESTFLAGS := +LDFLAGS := -w -s +GOFLAGS := +CGO_ENABLED ?= 0 + +BUILD_DATE := $(shell date -u '+%Y-%m-%d %I:%M:%S UTC' 2> /dev/null) +GIT_HASH := $(shell git rev-parse HEAD 2> /dev/null) + +LDFLAGS += -X "main.BuildDate=$(BUILD_DATE)" +LDFLAGS += -X main.Commit=$(GIT_HASH) + +GO_MOD := @go mod + + +HELM_3_PLUGINS = $(shell helm env HELM_PLUGINS) +HELM_PLUGIN_DIR = $(HELM_3_PLUGINS)/$(PROJECT_PLUGIN_SHORTNAME) + +.PHONY: all +all: build + +# ------------------------------------------------------------------------------ +# build + +.PHONY: build +build: $(BINDIR)/$(BINNAME) + +$(BINDIR)/$(BINNAME): $(SRC) + GO111MODULE=on CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/$(TOOL) + +# ------------------------------------------------------------------------------ +# install + +.PHONY: install +install: build + mkdir -p "$(HELM_PLUGIN_DIR)/bin" + cp "$(BINDIR)/$(BINNAME)" "$(HELM_PLUGIN_DIR)/bin" + cp plugin.yaml "$(HELM_PLUGIN_DIR)/" + + +# ------------------------------------------------------------------------------ +# test + +.PHONY: test +test: build +test: test-style +test: test-unit + +.PHONY: test-unit +test-unit: + @echo + @echo "==> Running unit tests <==" + GO111MODULE=on go test $(GOFLAGS) -run $(TESTS) ./... $(TESTFLAGS) + +.PHONY: test-coverage +test-coverage: + @echo + @echo "==> Running unit tests with coverage <==" + @mkdir -p $(BUILD_DIR) + GO111MODULE=on go test -v -covermode=count -coverprofile=$(BUILD_DIR)/cover.out ./... + GO111MODULE=on go tool cover -html=$(BUILD_DIR)/cover.out -o=$(BUILD_DIR)/coverage.html + +.PHONY: test-style +test-style: $(GOLANGCILINT) + GO111MODULE=on $(GOLANGCILINT) run + +.PHONY: format +format: $(GOIMPORTS) + GO111MODULE=on go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm + + +# ------------------------------------------------------------------------------ +# dependencies + +$(GOLANGCILINT): + (cd /; GO111MODULE=on go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2) + +$(GOIMPORTS): + (cd /; GO111MODULE=on go install golang.org/x/tools/cmd/goimports@latest) + + +# ------------------------------------------------------------------------------ + +.PHONY: clean +clean: + @rm -rf '$(BINDIR)/$(BINNAME)' + diff --git a/README.md b/README.md index 6ea2d8e..83b7c34 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,499 @@ -# distribution-tooling-for-helm +# Distribution Tooling for Helm -## Overview +`dt`, is a set of utilities available in a standalone mode and as a Helm Plugin for making offline work with Helm charts easier. It is meant to be used for creating reproducible and relocatable packages for Helm charts that can be easily moved across registries without hassles. This is particularly useful for distributing Helm charts into air-gapped environments like those used by Federal governments. -## Try it out +## TL;DR -### Prerequisites +Distribute your Helm charts with two easy commands -* Prereq 1 -* Prereq 2 -* Prereq 3 +```console +# Wrap +$ helm dt wrap oci://docker.io/bitnamicharts/kibana + ... + ๐ŸŽ‰ Helm chart wrapped into "/Users/martinpe/workspace/kibana/kibana-10.4.8.wrap.tgz" -### Build & Run +# Unwrap +$ helm dt unwrap kibana-10.4.8.wrap.tgz demo.goharbor.io/helm-plugin/ --yes + ... + ๐ŸŽ‰ Helm chart unwrapped successfully: You can use it now by running "helm install oci://demo.goharbor.io/helm-plugin/kibana --generate-name" +``` -1. Step 1 -2. Step 2 -3. Step 3 +![Helm distribution tooling demo](demo.gif) -## Documentation +This tool builds on [HIP-15](https://github.com/helm/community/blob/main/hips/hip-0015.md) and the, currently proposed, [images lock file HIP (PR)](https://github.com/helm/community/pull/281) as a foundation. Hence, it does require Helm charts to contain an annotation that provides the full list of container images that a Helm chart might need for its usage independently of the bootstrapping configuration. -## Contributing +[Bitnami Helm charts](https://github.com/bitnami/charts) are now fully annotated to support this tooling, but you can also use this set of utilities with any other Helm charts that might use any other alternative image listing annotation, like for example, Helm charts relying on [artifact.io/images](https://artifacthub.io/docs/topics/annotations/helm/). -The distribution-tooling-for-helm project team welcomes contributions from the community. Before you start working with distribution-tooling-for-helm, please -read our [Developer Certificate of Origin](https://cla.vmware.com/dco). All contributions to this repository must be -signed as described on that page. Your signature certifies that you wrote the patch or have the right to pass it on -as an open-source patch. For more detailed information, refer to [CONTRIBUTING.md](CONTRIBUTING.md). +## Installation -## License +### Installing as a Helm plugin +Provided you have [Helm](https://helm.sh) then you can install this tool as a plugin: + +```console +$ helm plugin install https://github.com/vmware-labs/distribution-tooling-for-helm +``` + +> **Note:** Windows installation +> +> If installing on Windows, the above command must be run in a bash emulator such as Git Bash. + +### Downloading and using standalone + +Fetch the latest available release from the [Releases](https://github.com/vmware-labs/distribution-tooling-for-helm/releases) section. + +Note that all the examples below use this tool as a Helm plugin but you can just run it as standalone. Just remove the `helm` command from all those examples. + +### Building from Source + +You can build this tool with the following command. Golang 1.20 or above is needed to compile. [golangci-lint](https://golangci-lint.run/usage/install/) is used for linting. + +```console +$ make build +``` + +You can also verify the build by running the unit tests: + +```console +$ make test +``` + +## Basic Usage + +The following sections list the most common commands and their usage. This tool can be used either standalone or through the Helm plugin. + +For the sake of following this guide, let's pull one of the Bitnami Helm charts into an examples folder: + +```console +$ git clone git@github.com:vmware-labs/distribution-tooling-for-helm.git +$ cd distribution-tooling-for-helm +$ bash -c "mkdir examples & helm pull oci://docker.io/bitnamicharts/mariadb -d examples --untar" +``` + +The two simplest and most powerful commands on this tool are `wrap` and `unwrap`. With these two commands **you can relocate any Helm chart to any OCI registry in two steps**. + +### Wrapping Helm charts + +Wrapping a chart consists of packaging the chart into a tar.gz, including all container images that this chart depends on, independently of values. Everything gets wrapped together into a single file. This will include also all the subcharts and their container images. That new file, the wrap, can be distributed around in whatever way you want (e.g. USB stick) to then later be unwrapped into a destination OCI registry. This process is commonly referred to as relocating a Helm chart. + +Even more exciting, we don't need to download the Helm chart for wrapping it. We can point the tool to any reachable Helm chart and the tool will take care of packaging and downloading everything for us. For example: + +```console +$ helm dt wrap oci://docker.io/bitnamicharts/kibana + ยป Wrapping Helm chart "oci://docker.io/bitnamicharts/kibana" + โœ” Helm chart downloaded to "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-1177363375/chart-1516625348/kibana" + โœ” Images.lock file "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-1177363375/chart-1516625348/kibana/Images.lock" does not exist + โœ” Images.lock file written to "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-1177363375/chart-1516625348/kibana/Images.lock" + ยป Pulling images into "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-1177363375/chart-1516625348/kibana/images" + โœ” All images pulled successfully + โœ” Helm chart wrapped to "/Users/martinpe/workspace/kibana/kibana-10.4.8.wrap.tgz" + ๐ŸŽ‰ Helm chart wrapped into "/Users/martinpe/workspace/kibana/kibana-10.4.8.wrap.tgz" +``` + +Note that depending on the number of images needed by the Helm chart (remember, a wrap has the full set of image dependencies, not only the ones set on _values.yaml_) the size of the generated wrap might be considerably large: + +```console +$ ls -l kibana-10.4.8.wrap.tgz +-rw-r--r-- 1 martinpe staff 731200979 Aug 4 15:17 kibana-10.4.8.wrap.tgz +``` + +If you want to make changes on the Helm chart, you can pass a directory to the wrap command. For example, if we wanted to wrap the previously pulled mariadb Helm chart, we could just do: + +```console +$ helm dt wrap examples/mariadb/ + ยป Wrapping Helm chart "examples/mariadb/" + โœ” Images.lock file "/Users/martinpe/workspace/distribution-tooling-for-helm/examples/mariadb/Images.lock" does not exist + โœ” Images.lock file written to "/Users/martinpe/workspace/distribution-tooling-for-helm/examples/mariadb/Images.lock" + ยป Pulling images into "/Users/martinpe/workspace/distribution-tooling-for-helm/examples/mariadb/images" + โœ” All images pulled successfully + โœ” Helm chart wrapped to "/Users/martinpe/workspace/distribution-tooling-for-helm/mariadb-13.0.0.wrap.tgz" + ๐ŸŽ‰ Helm chart wrapped into "/Users/martinpe/workspace/distribution-tooling-for-helm/mariadb-13.0.0.wrap.tgz" +``` + +If your chart and docker images include artifacts such as signatures or metadata, you can also include them in the wrap using the `--fetch-artifacts` flag. + +Currently, `dt` supports moving artifacts that follow certain conventions. That is: + +- Cosign keys that are associated to the digest with a .sig suffix +- Metadata entries stored in a `sha256-digest.metadata` OCI entry + +For example: + +```console +$ helm dt wrap --fetch-artifacts oci://docker.io/bitnamicharts/kibana + ... + ๐ŸŽ‰ Helm chart wrapped into "/Users/martinpe/workspace/distribution-tooling-for-helm/kibana-10.4.8.wrap.tgz" + +$ tar -tzf "/Users/martinpe/workspace/distribution-tooling-for-helm/kibana-10.4.8.wrap.tgz" | grep artifacts +kibana-10.4.8/artifacts/images/kibana/kibana/8.10.4-debian-11-r0.sig +kibana-10.4.8/artifacts/images/kibana/kibana/8.10.4-debian-11-r0.metadata +kibana-10.4.8/artifacts/images/kibana/kibana/8.10.4-debian-11-r0.metadata.sig +... +``` + +> **Note:** Signatures +> +> Chart signatures are not bundled as they would be invalidated at chart unwrap because of the relocation. All the container images wrapped will maintain their signatures and metadata. + + +### Unwrapping Helm charts + +Unwrapping a Helm chart can be done either to a local folder or to a target OCI registry, being the latter the most powerful option. By unwrapping the Helm chart to a target OCI registry the `dt` tool will unwrap the wrapped file, proceed to push the container images into the target registry that you have specified, relocate the references from the Helm chart to the provided registry and finally push the relocated Helm chart to the registry as well. + +At that moment your Helm chart will be ready to be used from the target registry without any dependencies to the source. By default, the tool will run in dry-run mode and require you to confirm actions but you can speed everything up with the `--yes` parameter. + +```console +$ helm dt unwrap kibana-10.4.8.wrap.tgz demo.goharbor.io/helm-plugin/ --yes + ยป Unwrapping Helm chart "kibana-10.4.8.wrap.tgz" + โœ” Helm chart uncompressed to "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" + โœ” Helm chart relocated successfully + ยป The wrap includes the following 2 images: + + demo.goharbor.io/helm-plugin/bitnami/kibana:8.9.0-debian-11-r9 + demo.goharbor.io/helm-plugin/bitnami/os-shell:11-debian-11-r25 + + ยป Pushing Images + โœ” All images pushed successfully + โœ” Chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" lock is valid + + โ ‹ Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (0 โ ™ Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (0 โ น Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (0 โ ธ Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (1 โ ผ Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (1 โ ด Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (1 โ ฆ Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (1 โ ง Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (1 โ ‡ Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (2 โ  Pushing Helm chart "/var/folders/mn/j41xvgsx7l90_hn0hlwj9p180000gp/T/chart-586072428/at-wrap2428431258" to "oci://demo.goharbor.io/helm-plugin/" (2 โœ” Helm chart successfully pushed + + ๐ŸŽ‰ Helm chart unwrapped successfully: You can use it now by running "helm install oci://demo.goharbor.io/helm-plugin/kibana --generate-name" +``` + +If your wrap includes bundled artifacts (if you wrapped it using the `--fetch-artifacts` flag), they will be also pushed to the remote registry. + +## Advanced Usage + +That was all as per the basic most basic and powerful usage. If you're interested in some other additional goodies then we will dig next into some specific finer-grained commands. + +### Creating an images lock + +An images lock file, a.k.a. `Images.lock` is a new file that gets created inside the directory as per [this HIP submission](https://github.com/helm/community/pull/281) to Helm community. The `Images.lock` file contains the list of all the container images annotated within a Helm chart's `Chart.yaml` manifest, including also all the images from its subchart dependencies. Along with the images, some other metadata useful for automating processing and relocation is also added. + +So, for example, the mariadb Helm chart that we downloaded earlier, has an `images` annotation like this: + +```console +$ cat examples/mariadb/Chart.yaml | head -n 10 +``` + +```yaml +annotations: + category: Database + images: | + - image: docker.io/bitnami/mariadb:11.0.2-debian-11-r2 + name: mariadb + - image: docker.io/bitnami/mysqld-exporter:0.15.0-debian-11-r5 + name: mysqld-exporter + - image: docker.io/bitnami/os-shell:11-debian-11-r22 + name: os-shell + licenses: Apache-2.0 +``` + +We can run the following command to create the `Images.lock` for the above Helm chart: + +```console +$ helm dt images lock examples/mariadb +INFO[0005] Images.lock file written to "/Users/martinpe/workspace/distribution-tooling-for-helm/examples/mariadb/Images.lock" +``` + +And it should look similar to this: + +```console +$ cat examples/mariadb/Images.lock +``` + +```yaml +apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "2023-08-04T13:36:09.398772Z" + generatedBy: Distribution Tooling for Helm +chart: + name: mariadb + version: 13.0.0 +images: + - name: mariadb + image: docker.io/bitnami/mariadb:11.0.2-debian-11-r2 + chart: mariadb + digests: + - digest: sha256:d3006a4d980d82a28f433ae7af316c698738ba29a5a598d527751cb9139ab7ff + arch: linux/amd64 + - digest: sha256:3ec78b7c97020ca2340189b75eba4a92ccb0d858ee62dd89c6a9826fb20048c9 + arch: linux/arm64 + - name: mysqld-exporter + image: docker.io/bitnami/mysqld-exporter:0.15.0-debian-11-r5 + chart: mariadb + digests: + - digest: sha256:6f257cc719f5bbde118c15ad610dc27d773f80216adabf10e315fbcaff078615 + arch: linux/amd64 + - digest: sha256:e0c141706fd1ce9ec5276627ae53994343ec2719aba606c1dc228f9290698fc1 + arch: linux/arm64 + - name: os-shell + image: docker.io/bitnami/os-shell:11-debian-11-r22 + chart: mariadb + digests: + - digest: sha256:7082ebf5644cf4968ac635986ded132dd308c0b9c13138f093834f343cd47d7b + arch: linux/amd64 + - digest: sha256:232ca2da59e508978543c8b113675c239a581938c88cbfa1ff17e9b6e504dc1a + arch: linux/arm64 +``` + +By default `Images.lock` creation expects an `images` annotation in your Helm chart. However, this can be overridden by the `annotations-key` flag. This is useful for example when dealing with Helm charts that rely on a different annotation like `artifacthub.io/images` which has existed for a while. You can use this flag with most of the commands in this guide. + +```console +$ helm dt images lock ../charts/jenkins --annotations-key artifacthub.io/images +``` + +### Targetting specific architectures + +The above `lock` command can be constrained to specific architectures. This is pretty useful to create lighter wraps as many of the images will be dropped when wrapping. + +```console +$ helm dt images lock ../charts/jenkins --platform linux/amd64 +``` + +If we now look at generated `Images.lock` we will notice that it contains only `linux/amd64` digests: + +```yaml +apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "2023-08-04T14:24:18.515082Z" + generatedBy: Distribution Tooling for Helm +chart: + name: mariadb + version: 13.0.0 +images: + - name: mariadb + image: docker.io/bitnami/mariadb:11.0.2-debian-11-r2 + chart: mariadb + digests: + - digest: sha256:d3006a4d980d82a28f433ae7af316c698738ba29a5a598d527751cb9139ab7ff + arch: linux/amd64 + - name: mysqld-exporter + image: docker.io/bitnami/mysqld-exporter:0.15.0-debian-11-r5 + chart: mariadb + digests: + - digest: sha256:6f257cc719f5bbde118c15ad610dc27d773f80216adabf10e315fbcaff078615 + arch: linux/amd64 + - name: os-shell + image: docker.io/bitnami/os-shell:11-debian-11-r22 + chart: mariadb + digests: + - digest: sha256:7082ebf5644cf4968ac635986ded132dd308c0b9c13138f093834f343cd47d7b + arch: linux/amd64 +``` + +### Verifying an images lock + +The `verify` command can be used to validate the integrity of an `Images.lock` file in a given Helm chart. This command will try to validate that all upstream container images that will be pulled from the Helm chart match actually the image digests that exist in the actual lock file. + +With this command, you can make sure that when you distribute a Helm chart with its corresponding `Images.lock` then any customer will be able to validate that just exactly the images defined in the lock will be pulled. Note that this is exactly part of what the `unwrap` command does, to make sure that only exactly what was wrapped gets into the target registry. Signing and other types of provenance are out of the scope of this tool for the time being and need to be added manually with external tooling. This is an area that we are very eager to improve soon. + +```console +$ helm dt images verify examples/mariadb +INFO[0004] Helm chart "examples/mariadb" lock is valid +``` + +### Pulling Helm chart images + +Based on the `Images.lock` file, this command downloads all listed images into the `images/` subfolder. + +```console +$ helm dt images pull examples/mariadb +INFO[0000] Pulling images into "/Users/martinpe/workspace/distribution-tooling-for-helm/examples/mariadb/images" +INFO[0022] All images pulled successfully +INFO[0022] Success +``` + +Then, in the `images` folder we should have something like + +```console +$ ls -1 examples/mariadb/images +232ca2da59e508978543c8b113675c239a581938c88cbfa1ff17e9b6e504dc1a.tar +3ec78b7c97020ca2340189b75eba4a92ccb0d858ee62dd89c6a9826fb20048c9.tar +6f257cc719f5bbde118c15ad610dc27d773f80216adabf10e315fbcaff078615.tar +7082ebf5644cf4968ac635986ded132dd308c0b9c13138f093834f343cd47d7b.tar +d3006a4d980d82a28f433ae7af316c698738ba29a5a598d527751cb9139ab7ff.tar +e0c141706fd1ce9ec5276627ae53994343ec2719aba606c1dc228f9290698fc1.tar +``` + +### Relocating a chart + +This command will relocate a Helm chart rewriting the `Images.lock` and all of its subchart dependencies locks as well. Additionally, it will change the `Chart.yaml` annotations, and any images used inside `values.yaml` (and all those on subchart dependencies as well). + +For example + +```console +$ helm dt charts relocate examples/mariadb acme.com/federal +INFO[0000] Helm chart relocated successfully +``` + +And we can check that references have indeed changed: + +```console +$ cat examples/mariadb/Images.lock |grep image +``` + +```yaml +images: + image: acme.com/federal/bitnami/mariadb:11.0.2-debian-11-r2 + image: acme.com/federal/bitnami/mysqld-exporter:0.15.0-debian-11-r5 + image: acme.com/federal/bitnami/os-shell:11-debian-11-r22 +``` + +### Pushing images + +Based on the `Images.lock` file, this command pushes all images (that must have been previously pulled into the `images/` folder) into their respective registries. Note that this command does not relocate anything. It will simply try to push the images to wherever they are pointing. + +Obviously, this command only makes sense when used after having pulled the images and executed the `relocate` command. + +```console +# .. should have pulled images first .. +# .. then relocate to a target registry .. +# and now... +$ helm dt images push examples/mariadb +INFO[0033] All images pushed successfully +``` + +### Getting information about a wrapped chart + +It is sometimes useful to obtain information about a wrapped chart before unwrapping it. For this purpose, you can use the info command: + +```console +$ helm dt info wordpress-16.1.24.wrap.tgz + ยป Wrap Information + Chart: wordpress + Version: 16.1.24 + ยป Metadata + - generatedBy: Distribution Tooling for Helm + - generatedAt: 2023-08-18T12:52:55.824345304Z + ยป Images + docker.io/bitnami/apache-exporter:0.13.4-debian-11-r12 (linux/amd64, linux/arm64) + docker.io/bitnami/bitnami-shell:11-debian-11-r132 (linux/amd64, linux/arm64) + docker.io/bitnami/wordpress:6.2.2-debian-11-r26 (linux/amd64, linux/arm64) + docker.io/bitnami/bitnami-shell:11-debian-11-r123 (linux/amd64, linux/arm64) + docker.io/bitnami/mariadb:10.11.4-debian-11-r0 (linux/amd64, linux/arm64) + docker.io/bitnami/mysqld-exporter:0.14.0-debian-11-r125 (linux/amd64, linux/arm64) + docker.io/bitnami/bitnami-shell:11-debian-11-r130 (linux/amd64, linux/arm64) + docker.io/bitnami/memcached:1.6.21-debian-11-r4 (linux/amd64, linux/arm64) + docker.io/bitnami/memcached-exporter:0.13.0-debian-11-r8 (linux/amd64, linux/arm64) +``` + +If you are interested in getting the image digests, you can use the `--detailed` flag: + +```console +$ helm dt info --detailed wordpress-16.1.24.wrap.tgz + ยป Wrap Information + Chart: wordpress + Version: 16.1.24 + ยป Metadata + - generatedBy: Distribution Tooling for Helm + - generatedAt: 2023-08-18T12:52:55.824345304Z + ยป Images + ยป wordpress/apache-exporter + Image: docker.io/bitnami/apache-exporter:0.13.4-debian-11-r12 + Digests + - Arch: linux/amd64 + Digest: sha256:0b4373c3571d5640320b68f8d296c0a4eaf7704947214640b77528bb4d79d23c + - Arch: linux/arm64 + Digest: sha256:895ba569e4db3188798e445fe3be2e4da89fd85cb8ae0c5ef0bd2a67cfe4305c +... + ยป mariadb/bitnami-shell + Image: docker.io/bitnami/bitnami-shell:11-debian-11-r123 + Digests + - Arch: linux/amd64 + Digest: sha256:13d8883d4f40612e8a231c5d9fa8c4efa74d2a62f0a1991f20fc32c5debdd2b1 + - Arch: linux/arm64 + Digest: sha256:74579dc63b3ae7d8ec21a6ffcd47d16781582fef8dd5a28e77844fcbcb1072c1 +... +``` + +It is also possible to get a YAML dump if the `Images.lock` in case you need to feed it to another process: + +```console +$ helm dt info --yaml wordpress-16.1.24.wrap.tgz +``` + +```yaml +apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "2023-08-18T12:52:55.824345304Z" + generatedBy: Distribution Tooling for Helm +chart: + name: wordpress + version: 16.1.24 +images: + - name: apache-exporter + image: docker.io/bitnami/apache-exporter:0.13.4-debian-11-r12 + chart: wordpress + digests: + - digest: sha256:0b4373c3571d5640320b68f8d296c0a4eaf7704947214640b77528bb4d79d23c + arch: linux/amd64 + - digest: sha256:895ba569e4db3188798e445fe3be2e4da89fd85cb8ae0c5ef0bd2a67cfe4305c + arch: linux/arm64 +... +``` + +### Annotating a Helm chart (EXPERIMENTAL) + +`Images.lock` creation relies on the existence of the special images annotation inside `Chart.yaml`. If you have a Helm chart that does not contain any annotations, this command can be used to guess and generate an annotation with a tentative list of images. It's important to note that this list is a **best-effort** as the list of images is obtained from the `values.yaml` file and this is always an unreliable, often incomplete, and error-prone source as the configuration in `values.yaml` is very variable. + +```console +$ helm dt charts annotate examples/mariadb +INFO[0000] Helm chart annotated successfully +``` + +### Converting a Helm chart into a Carvel bundle (EXPERIMENTAL) + +From `dt` v0.2.0 we have introduced a new command to create a [Carvel bundle](https://carvel.dev/imgpkg/docs/v0.37.x/resources/#bundle) from any Helm chart. + + +```console +$ helm dt charts carvelize examples/postgresql + โœ” Helm chart "examples/postgresql" lock is valid + ยป Generating Carvel bundle for Helm chart "examples/postgresql" + โœ” Validating Carvel images lock + โœ” Carvel images lock written to "examples/postgresql/.imgpkg/images.yml" + โœ” Carvel metadata written to "examples/postgresql/.imgpkg/bundle.yml" + ๐ŸŽ‰ Carvel bundle created successfully +``` + +### Login and logout from OCI registries (EXPERIMENTAL) + +It is also possible to login and logout from OCI registries using the `dt` command. For example: + +```console +$ helm dt auth login 127.0.0.1:5000 -u testuser -p testpassword + โœ” log in to 127.0.0.1:5000 as user testuser + ๐ŸŽ‰ logged in via /Users/home/.docker/config.json +``` + +```console +$ helm dt auth logout 127.0.0.1:5000 + โœ” logout from 127.0.0.1:5000 + ๐ŸŽ‰ logged out via /Users/home/.docker/config.json +``` + +## Frequently Asked Questions + +### I cannot install the plugin due to `Error: Unable to update repository: exit status 1` + +This can happen when somehow the plugin process installation or removal breaks and the Helm plugin's cache gets corrupted. Try removing the plugin from the cache and reinstalling it. For example on MAC OSX it would be: + +```console +$ rm -rf $HOME/Library/Caches/helm/plugins/https-github.com-vmware-labs-distribution-tooling-for-helm +$ helm plugin install https://github.com/vmware-labs/distribution-tooling-for-helm +``` + +### How does this project relate to the [relok8s](https://github.com/vmware-tanzu/asset-relocation-tool-for-kubernetes)? Does it replace it? + +Good question. Both projects come from VMware and should be able to continue using [relok8s](https://github.com/vmware-tanzu/asset-relocation-tool-for-kubernetes) if you want to. Although, our expectation is to gradually build more and more tooling around the [HIP-15](https://github.com/helm/community/blob/main/hips/hip-0015.md) proposal as it does have a substantial number of benefits when compared to the relocation approach followed by relok8s. + +So as the community adopts this new proposal and this plugin becomes more mature we would suggest anyone using relok8s to move its platform scripts to start using this helm plugin. We expect this move to be pretty much straightforward, and actually a great simplification for anyone using relok8s or even chart-syncer. + +### What about chart-syncer? Will it continue to work? + +Yes, still support [chart-syncer](https://github.com/bitnami-labs/charts-syncer) and we don't have any short-term plans right now about it. But as this tool gains adoption, it becomes natural to think that it should be fairly straightforward to implement Helm chart syncing on top of it. diff --git a/SECURITY.MD b/SECURITY.MD new file mode 100644 index 0000000..9d9739d --- /dev/null +++ b/SECURITY.MD @@ -0,0 +1,72 @@ +# Security Release Process + +The community has adopted this security disclosure and response policy to ensure we responsibly handle critical issues. + + +## Supported Versions + +For a list of support versions that this project will potentially create security fixes for, please refer to the Releases page on this project's GitHub and/or project related documentation on release cadence and support. + + +## Reporting a Vulnerability - Private Disclosure Process + +Security is of the highest importance and all security vulnerabilities or suspected security vulnerabilities should be reported to this project privately, to minimize attacks against current users before they are fixed. Vulnerabilities will be investigated and patched on the next patch (or minor) release as soon as possible. This information could be kept entirely internal to the project. + +If you know of a publicly disclosed security vulnerability for this project, please **IMMEDIATELY** contact the maintainers of this project privately. The use of encrypted email is encouraged. + + +**IMPORTANT: Do not file public issues on GitHub for security vulnerabilities** + +To report a vulnerability or a security-related issue, please contact the maintainers with enough details through one of the following channels: +* Directly via their individual email addresses +* Open a [GitHub Security Advisory](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability). This allows for anyone to report security vulnerabilities directly and privately to the maintainers via GitHub. Note that this option may not be present for every repository. + +The report will be fielded by the maintainers who have committer and release permissions. Feedback will be sent within 3 business days, including a detailed plan to investigate the issue and any potential workarounds to perform in the meantime. + +Do not report non-security-impacting bugs through this channel. Use GitHub issues for all non-security-impacting bugs. + + +## Proposed Report Content + +Provide a descriptive title and in the description of the report include the following information: + +* Basic identity information, such as your name and your affiliation or company. +* Detailed steps to reproduce the vulnerability (POC scripts, screenshots, and logs are all helpful to us). +* Description of the effects of the vulnerability on this project and the related hardware and software configurations, so that the maintainers can reproduce it. +* How the vulnerability affects this project's usage and an estimation of the attack surface, if there is one. +* List other projects or dependencies that were used in conjunction with this project to produce the vulnerability. + + +## When to report a vulnerability + +* When you think this project has a potential security vulnerability. +* When you suspect a potential vulnerability but you are unsure that it impacts this project. +* When you know of or suspect a potential vulnerability on another project that is used by this project. + + +## Patch, Release, and Disclosure + +The maintainers will respond to vulnerability reports as follows: + +1. The maintainers will investigate the vulnerability and determine its effects and criticality. +2. If the issue is not deemed to be a vulnerability, the maintainers will follow up with a detailed reason for rejection. +3. The maintainers will initiate a conversation with the reporter within 3 business days. +4. If a vulnerability is acknowledged and the timeline for a fix is determined, the maintainers will work on a plan to communicate with the appropriate community, including identifying mitigating steps that affected users can take to protect themselves until the fix is rolled out. +5. The maintainers will also create a [Security Advisory](https://docs.github.com/en/code-security/repository-security-advisories/publishing-a-repository-security-advisory) using the [CVSS Calculator](https://www.first.org/cvss/calculator/3.0), if it is not created yet. The maintainers make the final call on the calculated CVSS; it is better to move quickly than making the CVSS perfect. Issues may also be reported to [Mitre](https://cve.mitre.org/) using this [scoring calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator). The draft advisory will initially be set to private. +6. The maintainers will work on fixing the vulnerability and perform internal testing before preparing to roll out the fix. +7. Once the fix is confirmed, the maintainers will patch the vulnerability in the next patch or minor release, and backport a patch release into all earlier supported releases. + + +## Public Disclosure Process + +The maintainers publish the public advisory to this project's community via GitHub. In most cases, additional communication via Slack, Twitter, mailing lists, blog, and other channels will assist in educating the project's users and rolling out the patched release to affected users. + +The maintainers will also publish any mitigating steps users can take until the fix can be applied to their instances. This project's distributors will handle creating and publishing their own security advisories. + + +## Confidentiality, integrity and availability + +We consider vulnerabilities leading to the compromise of data confidentiality, elevation of privilege, or integrity to be our highest priority concerns. Availability, in particular in areas relating to DoS and resource exhaustion, is also a serious security concern. The maintainer team takes all vulnerabilities, potential vulnerabilities, and suspected vulnerabilities seriously and will investigate them in an urgent and expeditious manner. + +Note that we do not currently consider the default settings for this project to be secure-by-default. It is necessary for operators to explicitly configure settings, role based access control, and other resource related features in this project to provide a hardened environment. We will not act on any security disclosure that relates to a lack of safe defaults. Over time, we will work towards improved safe-by-default configuration, taking into account backwards compatibility. + diff --git a/cmd/dt/annotate/annotate.go b/cmd/dt/annotate/annotate.go new file mode 100644 index 0000000..e12a8e5 --- /dev/null +++ b/cmd/dt/annotate/annotate.go @@ -0,0 +1,51 @@ +// Package annotate implements the dt charts annotate command +package annotate + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" +) + +// NewCmd builds a new annotate command +func NewCmd(cfg *config.Config) *cobra.Command { + return &cobra.Command{ + Use: "annotate CHART_PATH", + Short: "Annotates a Helm chart (Experimental)", + Long: `Experimental. Tries to annotate a Helm chart by guesing the container images from the information at values.yaml. + +Use it cautiously. Very often the complete list of images cannot be guessed from information in values.yaml`, + Example: ` # Annotate an example Helm chart + $ dt charts annotate examples/mongodb`, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + chartPath := args[0] + l := cfg.Logger() + + err := l.ExecuteStep(fmt.Sprintf("Annotating Helm chart %q", chartPath), func() error { + return chartutils.AnnotateChart(chartPath, + chartutils.WithAnnotationsKey(cfg.AnnotationsKey), + chartutils.WithLog(l), + ) + + }) + + if err != nil { + if errors.Is(err, chartutils.ErrNoImagesToAnnotate) { + l.Warnf("No container images found to be annotated") + return nil + } + return l.Failf("failed to annotate Helm chart %q: %v", chartPath, err) + } + + l.Successf("Helm chart annotated successfully") + + return nil + }, + } +} diff --git a/cmd/dt/annotate_test.go b/cmd/dt/annotate_test.go new file mode 100644 index 0000000..39de450 --- /dev/null +++ b/cmd/dt/annotate_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + + "helm.sh/helm/v3/pkg/chart/loader" +) + +type testImage struct { + Name string + Registry string + Repository string + Tag string + Digest string +} + +func (img *testImage) URL() string { + return fmt.Sprintf("%s/%s:%s", img.Registry, img.Repository, img.Tag) +} + +func (suite *CmdSuite) TestAnnotateCommand() { + sb := suite.sb + t := suite.T() + require := suite.Require() + assert := suite.Assert() + + serverURL := "localhost" + scenarioName := "plain-chart" + defaultAnnotationsKey := "images" + customAnnotationsKey := "artifacthub.io/images" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + images := []testImage{ + { + Name: "bitnami-shell", + Registry: "docker.io", + Repository: "bitnami/bitnami-shell", + Tag: "1.0.0", + }, + { + Name: "wordpress", + Registry: "docker.io", + Repository: "bitnami/wordpress", + Tag: "latest", + }, + } + for title, key := range map[string]string{ + "Successfully annotates a Helm chart": "", + "Successfully annotates a Helm chart with custom key": customAnnotationsKey, + } { + t.Run(title, func(t *testing.T) { + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "ValuesImages": images}, + )) + var args []string + if key == "" || key == defaultAnnotationsKey { + // enforce it if empty + if key == "" { + key = defaultAnnotationsKey + } + args = []string{"charts", "annotate", chartDir} + } else { + args = []string{"charts", "--annotations-key", key, "annotate", chartDir} + } + dt(args...).AssertSuccess(t) + + expectedImages := make([]tu.AnnotationEntry, 0) + for _, img := range images { + expectedImages = append(expectedImages, tu.AnnotationEntry{ + Name: img.Name, + Image: img.URL(), + }) + } + tu.AssertChartAnnotations(t, chartDir, key, expectedImages) + }) + } + t.Run("Corner cases", func(t *testing.T) { + t.Run("Handle empty image list case", func(t *testing.T) { + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL}, + )) + + dt("charts", "annotate", chartDir).AssertSuccessMatch(t, regexp.MustCompile(`No container images found`)) + + tu.AssertChartAnnotations(t, chartDir, defaultAnnotationsKey, make([]tu.AnnotationEntry, 0)) + + c, err := loader.Load(chartDir) + require.NoError(err) + + assert.Equal(0, len(c.Metadata.Annotations)) + }) + t.Run("Handle errors annotating", func(t *testing.T) { + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "ValuesImages": images}, + )) + require.NoError(os.Chmod(chartDir, os.FileMode(0555))) + // Make sure the sandbox can be cleaned + defer os.Chmod(chartDir, os.FileMode(0755)) + + dt("charts", "annotate", chartDir).AssertErrorMatch(t, regexp.MustCompile(`failed to annotate Helm chart.*failed to serialize.*`)) + }) + }) +} diff --git a/cmd/dt/auth.go b/cmd/dt/auth.go new file mode 100644 index 0000000..9d31b0b --- /dev/null +++ b/cmd/dt/auth.go @@ -0,0 +1,21 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/login" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/logout" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Authentication commands", + SilenceUsage: true, + SilenceErrors: true, + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, +} + +func init() { + authCmd.AddCommand(login.NewCmd(mainConfig), logout.NewCmd(mainConfig)) +} diff --git a/cmd/dt/auth_test.go b/cmd/dt/auth_test.go new file mode 100644 index 0000000..dd2e824 --- /dev/null +++ b/cmd/dt/auth_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "testing" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/stretchr/testify/require" + "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestLoginLogout(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + ociSrv, err := testutil.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + go ociSrv.ListenAndServe() + + t.Run("can't get catalog without login", func(t *testing.T) { + _, err := crane.Catalog(ociSrv.RegistryURL) + require.ErrorContains(t, err, "UNAUTHORIZED") + }) + + t.Run("can get catalog after login", func(t *testing.T) { + dt("auth", "login", ociSrv.RegistryURL, "-u", "username", "-p", "password").AssertSuccessMatch(t, "logged in via") + _, err := crane.Catalog(ociSrv.RegistryURL) + require.NoError(t, err) + + dt("auth", "logout", ociSrv.RegistryURL).AssertSuccessMatch(t, "logged out via") + _, err = crane.Catalog(ociSrv.RegistryURL) + require.ErrorContains(t, err, "UNAUTHORIZED") + }) +} diff --git a/cmd/dt/carvelize/carvelize.go b/cmd/dt/carvelize/carvelize.go new file mode 100644 index 0000000..cbafa2b --- /dev/null +++ b/cmd/dt/carvelize/carvelize.go @@ -0,0 +1,144 @@ +// Package carvelize provides the carvelize command +package carvelize + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/lock" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/verify" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/carvel" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +// NewCmd builds a new carvelize command +func NewCmd(cfg *config.Config) *cobra.Command { + var yamlFormat bool + var showDetails bool + + cmd := &cobra.Command{ + Use: "carvelize FILE", + Short: "Adds a Carvel bundle to the Helm chart (Experimental)", + Long: `Experimental. Adds a Carvel bundle to an existing Helm chart`, + Example: ` # Adds a Carvel bundle to a Helm chart + $ dt charts carvelize examples/mariadb`, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + chartPath := args[0] + l := cfg.Logger() + // Allows silencing called methods + silentLog := silent.NewLogger() + + lockFile, err := chartutils.GetImageLockFilePath(chartPath) + if err != nil { + return fmt.Errorf("failed to determine Images.lock file location: %w", err) + } + + if utils.FileExists(lockFile) { + if err := l.ExecuteStep("Verifying Images.lock", func() error { + return verify.Lock(chartPath, lockFile, verify.Config{Insecure: cfg.Insecure, AnnotationsKey: cfg.AnnotationsKey}) + }); err != nil { + return l.Failf("Failed to verify lock: %w", err) + } + l.Infof("Helm chart %q lock is valid", chartPath) + + } else { + err := l.ExecuteStep( + "Images.lock file does not exist. Generating it from annotations...", + func() error { + return lock.Create(chartPath, + lockFile, silentLog, imagelock.WithAnnotationsKey(cfg.AnnotationsKey), imagelock.WithInsecure(cfg.Insecure), + ) + }, + ) + if err != nil { + return l.Failf("Failed to generate lock: %w", err) + } + l.Infof("Images.lock file written to %q", lockFile) + } + if err := l.Section(fmt.Sprintf("Generating Carvel bundle for Helm chart %q", chartPath), func(childLog log.SectionLogger) error { + if err := GenerateBundle( + chartPath, + chartutils.WithAnnotationsKey(cfg.AnnotationsKey), + chartutils.WithLog(childLog), + ); err != nil { + return childLog.Failf("%v", err) + } + return nil + }); err != nil { + return l.Failf("%w", err) + } + l.Successf("Carvel bundle created successfully") + return nil + }, + } + cmd.PersistentFlags().BoolVar(&yamlFormat, "yaml", yamlFormat, "Show report in YAML format") + cmd.PersistentFlags().BoolVar(&showDetails, "detailed", showDetails, "When using the printable report, add more details about the bundled images") + + return cmd +} + +// GenerateBundle generates a Carvel bundle for a Helm chart +func GenerateBundle(chartPath string, opts ...chartutils.Option) error { + cfg := chartutils.NewConfiguration(opts...) + l := cfg.Log + + lock, err := chartutils.ReadLockFromChart(chartPath) + if err != nil { + return fmt.Errorf("failed to load Images.lock: %v", err) + } + + imgPkgPath := filepath.Join(chartPath, ".imgpkg") + if !utils.FileExists(imgPkgPath) { + err := os.Mkdir(imgPkgPath, os.FileMode(0755)) + if err != nil { + return fmt.Errorf("failed to create .imgpkg directory: %w", err) + } + } + + bundleMetadata, err := carvel.CreateBundleMetadata(chartPath, lock, cfg) + if err != nil { + return fmt.Errorf("failed to prepare Carvel bundle: %w", err) + } + + carvelImagesLock, err := carvel.CreateImagesLock(lock) + if err != nil { + return fmt.Errorf("failed to prepare Carvel images lock: %w", err) + } + l.Infof("Validating Carvel images lock") + + err = carvelImagesLock.Validate() + if err != nil { + return fmt.Errorf("failed to validate Carvel images lock: %w", err) + } + + path := filepath.Join(imgPkgPath, "images.yml") + err = carvelImagesLock.WriteToPath(path) + if err != nil { + return fmt.Errorf("Could not write image lock: %v", err) + } + l.Infof("Carvel images lock written to %q", path) + + buff := &bytes.Buffer{} + if err = bundleMetadata.ToYAML(buff); err != nil { + return fmt.Errorf("failed to write bundle metadata file: %v", err) + } + + path = imgPkgPath + "/bundle.yml" + if err := os.WriteFile(path, buff.Bytes(), 0666); err != nil { + return fmt.Errorf("failed to write Carvel bundle metadata to %q: %w", path, err) + } + l.Infof("Carvel metadata written to %q", path) + return nil +} diff --git a/cmd/dt/carvelize_test.go b/cmd/dt/carvelize_test.go new file mode 100644 index 0000000..74683f9 --- /dev/null +++ b/cmd/dt/carvelize_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + carvel "github.com/vmware-labs/distribution-tooling-for-helm/pkg/carvel" + "gopkg.in/yaml.v3" +) + +func (suite *CmdSuite) TestCarvelizeCommand() { + require := suite.Require() + + s, err := tu.NewTestServer() + require.NoError(err) + + defer s.Close() + + images, err := s.LoadImagesFromFile("../../testdata/images.json") + require.NoError(err) + + sb := suite.sb + serverURL := s.ServerURL + scenarioName := "custom-chart" + chartName := "test" + authors := []carvel.Author{{ + Name: "VMware, Inc.", + Email: "dt@vmware.com", + }} + websites := []carvel.Website{{ + URL: "https://github.com/bitnami/charts/tree/main/bitnami/wordpress", + }} + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + t := suite.T() + + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL, + "Authors": authors, "Websites": websites, + }, + )) + + bundleData, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, ".imgpkg/bundle.yml.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, + "Authors": authors, "Websites": websites, + }, + ) + + require.NoError(err) + var expectedBundle map[string]interface{} + require.NoError(yaml.Unmarshal([]byte(bundleData), &expectedBundle)) + + carvelImagesData, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, ".imgpkg/images.yml.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, + ) + require.NoError(err) + var expectedCarvelImagesLock map[string]interface{} + require.NoError(yaml.Unmarshal([]byte(carvelImagesData), &expectedCarvelImagesLock)) + + // We need to provide the --insecure flag or our test server won't validate + args := []string{"charts", "carvelize", "--insecure", chartDir} + res := dt(args...) + res.AssertSuccess(t) + + t.Run("Generates Carvel bundle", func(_ *testing.T) { + newBundleData, err := os.ReadFile(filepath.Join(chartDir, carvel.CarvelBundleFilePath)) + require.NoError(err) + var newBundle map[string]interface{} + require.NoError(yaml.Unmarshal(newBundleData, &newBundle)) + + require.Equal(expectedBundle, newBundle) + }) + + t.Run("Generates Carvel images", func(_ *testing.T) { + newImagesData, err := os.ReadFile(filepath.Join(chartDir, carvel.CarvelImagesFilePath)) + require.NoError(err) + var newImagesLock map[string]interface{} + require.NoError(yaml.Unmarshal(newImagesData, &newImagesLock)) + + require.Equal(expectedCarvelImagesLock, newImagesLock) + }) +} diff --git a/cmd/dt/chart.go b/cmd/dt/chart.go new file mode 100644 index 0000000..20a369f --- /dev/null +++ b/cmd/dt/chart.go @@ -0,0 +1,22 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/annotate" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/carvelize" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/relocate" +) + +var chartCmd = &cobra.Command{ + Use: "charts", + Short: "Helm chart management commands", + SilenceUsage: true, + SilenceErrors: true, + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, +} + +func init() { + chartCmd.AddCommand(relocate.NewCmd(mainConfig), annotate.NewCmd(mainConfig), carvelize.NewCmd(mainConfig)) +} diff --git a/cmd/dt/chart_test.go b/cmd/dt/chart_test.go new file mode 100644 index 0000000..61b0a13 --- /dev/null +++ b/cmd/dt/chart_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "testing" +) + +func (suite *CmdSuite) TestChartsHelp() { + t := suite.T() + t.Run("Shows Help", func(t *testing.T) { + res := dt("charts") + res.AssertSuccess(t) + for _, reStr := range []string{ + `annotate\s+Annotates a Helm chart`, + `carvelize\s+Adds a Carvel bundle to the Helm chart`, + `relocate\s+Relocates a Helm chart`, + } { + res.AssertSuccessMatch(t, fmt.Sprintf(`(?s).*Available Commands:.*\n\s*%s.*`, reStr)) + } + }) +} diff --git a/cmd/dt/config/config.go b/cmd/dt/config/config.go new file mode 100644 index 0000000..fb094ed --- /dev/null +++ b/cmd/dt/config/config.go @@ -0,0 +1,128 @@ +// Package config defines the configuration of the dt tool +package config + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + ll "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/logrus" + + pl "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/pterm" +) + +// Config defines the configuration of the dt tool +type Config struct { + Insecure bool + logger log.SectionLogger + Context context.Context + AnnotationsKey string + TempDirectory string + UsePlainHTTP bool + + LogLevel string + UsePlainLog bool +} + +// NewConfig returns a new Config +func NewConfig() *Config { + return &Config{ + Context: context.Background(), + AnnotationsKey: imagelock.DefaultAnnotationsKey, + LogLevel: "info", + UsePlainLog: false, + } +} + +// Logger returns the current SectionLogger, creating it if necessary +func (c *Config) Logger() log.SectionLogger { + if c.logger == nil { + + var l log.SectionLogger + if c.UsePlainLog { + l = ll.NewSectionLogger() + } else { + l = pl.NewSectionLogger() + } + lvl, err := log.ParseLevel(c.LogLevel) + + if err != nil { + l.Warnf("Invalid log level %s: %v", c.LogLevel, err) + } + + l.SetLevel(lvl) + c.logger = l + } + return c.logger +} + +// GetTemporaryDirectory returns the temporary directory of the Config +func (c *Config) GetTemporaryDirectory() (string, error) { + if c.TempDirectory != "" { + return c.TempDirectory, nil + } + + dir, err := os.MkdirTemp("", "chart-*") + if err != nil { + return "", err + } + c.TempDirectory = dir + return dir, nil +} + +// ContextWithSigterm returns a context that is canceled when the process receives a SIGTERM +func (c *Config) ContextWithSigterm() (context.Context, context.CancelFunc) { + ctx, stop := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM) + // If we are done, call stop right away so we restore signal behavior + go func() { + defer stop() + <-ctx.Done() + }() + return ctx, stop + +} + +var ( + // KeepArtifacts is a flag that indicates whether artifacts should be kept + KeepArtifacts bool + + // global temporary directory used to store different assets + globalTempWorkDir string + globalTempWorkDirMutex = &sync.RWMutex{} +) + +// CleanGlobalTempWorkDir removes the global temporary directory +func CleanGlobalTempWorkDir() error { + globalTempWorkDirMutex.Lock() + defer globalTempWorkDirMutex.Unlock() + + if globalTempWorkDir == "" || KeepArtifacts { + return nil + } + if err := os.RemoveAll(globalTempWorkDir); err != nil { + return fmt.Errorf("failed to remove temporary directory %q: %w", globalTempWorkDir, err) + } + globalTempWorkDir = "" + return nil +} + +// GetGlobalTempWorkDir returns the current global directory or +// creates a new one if none has been created yet +func GetGlobalTempWorkDir() (string, error) { + globalTempWorkDirMutex.Lock() + defer globalTempWorkDirMutex.Unlock() + + if globalTempWorkDir == "" { + dir, err := os.MkdirTemp("", "chart-*") + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + globalTempWorkDir = dir + } + return globalTempWorkDir, nil +} diff --git a/cmd/dt/dt.go b/cmd/dt/dt.go new file mode 100644 index 0000000..7cc050b --- /dev/null +++ b/cmd/dt/dt.go @@ -0,0 +1,19 @@ +// Package main implements the dt tool +package main + +import ( + "fmt" + "os" + + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" +) + +func main() { + // Make sure we clean up after ourselves + defer config.CleanGlobalTempWorkDir() + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/dt/dt_test.go b/cmd/dt/dt_test.go new file mode 100644 index 0000000..ab6d44b --- /dev/null +++ b/cmd/dt/dt_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "flag" + "os" + "os/exec" + "regexp" + "syscall" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestMain(m *testing.M) { + if os.Getenv("BE_DT") == "1" { + main() + os.Exit(0) + return + } + flag.Parse() + c := m.Run() + os.Exit(c) +} + +type CmdSuite struct { + suite.Suite + sb *tu.Sandbox +} + +func (suite *CmdSuite) TearDownSuite() { + suite.sb.Cleanup() +} + +func (suite *CmdSuite) AssertPanicsMatch(fn func(), re *regexp.Regexp) bool { + return tu.AssertPanicsMatch(suite.T(), fn, re) +} + +func (suite *CmdSuite) SetupSuite() { + suite.sb = tu.NewSandbox() +} + +// dt calls the dt command externally via exec +func dt(cmdArgs ...string) CmdResult { + return execCommand(cmdArgs...) +} + +func TestDtToolCommand(t *testing.T) { + suite.Run(t, new(CmdSuite)) +} + +func execCommand(args ...string) CmdResult { + var buffStdout, buffStderr bytes.Buffer + code := 0 + + cmd := exec.Command(os.Args[0], args...) + cmd.Stdout = &buffStdout + cmd.Stderr = &buffStderr + + cmd.Env = append(os.Environ(), "BE_DT=1") + + err := cmd.Run() + + if err != nil { + code = err.(*exec.ExitError).Sys().(syscall.WaitStatus).ExitStatus() + } + + return CmdResult{code: code, stdout: buffStdout.String(), stderr: buffStderr.String()} +} + +type CmdResult struct { + code int + stdout string + stderr string +} + +func (r CmdResult) AssertErrorMatch(t *testing.T, re interface{}) bool { + if r.AssertError(t) { + return assert.Regexp(t, re, r.stderr) + } + return true +} + +func (r CmdResult) AssertSuccessMatch(t *testing.T, re interface{}) bool { + if r.AssertSuccess(t) { + return assert.Regexp(t, re, r.stdout) + } + return true +} +func (r CmdResult) AssertCode(t *testing.T, code int) bool { + return assert.Equal(t, code, r.code, "Expected %d code but got %d", code, r.code) +} +func (r CmdResult) AssertSuccess(t *testing.T) bool { + return assert.True(t, r.Success(), "Expected command to success but got code=%d stderr=%s", r.code, r.stderr) +} + +func (r CmdResult) AssertError(t *testing.T) bool { + return assert.False(t, r.Success(), "Expected command to fail") +} + +func (r CmdResult) Success() bool { + return r.code == 0 +} diff --git a/cmd/dt/images.go b/cmd/dt/images.go new file mode 100644 index 0000000..599f038 --- /dev/null +++ b/cmd/dt/images.go @@ -0,0 +1,23 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/lock" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/pull" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/push" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/verify" +) + +var imagesCmd = &cobra.Command{ + Use: "images", + SilenceUsage: true, + SilenceErrors: true, + Short: "Container image management commands", + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, +} + +func init() { + imagesCmd.AddCommand(lock.NewCmd(mainConfig), verify.NewCmd(mainConfig), pull.NewCmd(mainConfig), push.NewCmd(mainConfig)) +} diff --git a/cmd/dt/images_test.go b/cmd/dt/images_test.go new file mode 100644 index 0000000..af43795 --- /dev/null +++ b/cmd/dt/images_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "testing" +) + +func (suite *CmdSuite) TestImagesHelp() { + t := suite.T() + t.Run("Shows Help", func(t *testing.T) { + res := dt("images") + res.AssertSuccess(t) + for _, reStr := range []string{ + `lock\s+Creates the lock file`, + `pull\s+Pulls the images from the Images\.lock`, + `push\s+Pushes the images from Images\.lock`, + `verify\s+Verifies the images in an Images\.lock`, + } { + res.AssertSuccessMatch(t, fmt.Sprintf(`(?s).*Available Commands:.*\n\s*%s.*`, reStr)) + } + }) +} diff --git a/cmd/dt/info/info.go b/cmd/dt/info/info.go new file mode 100644 index 0000000..498b097 --- /dev/null +++ b/cmd/dt/info/info.go @@ -0,0 +1,92 @@ +// Package info implements the dt info command +package info + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +// NewCmd returns a new dt info command +func NewCmd(cfg *config.Config) *cobra.Command { + var yamlFormat bool + var showDetails bool + + cmd := &cobra.Command{ + Use: "info FILE", + Short: "shows info of a wrapped chart", + Long: `Shows information of a wrapped Helm chart, including the bundled images and chart metadata`, + Example: ` # Show information of a wrapped Helm chart + $ dt info mariadb-12.2.8.wrap.tgz`, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + chartPath := args[0] + l := cfg.Logger() + _, _ = chartPath, l + if !utils.FileExists(chartPath) { + return fmt.Errorf("wrap file %q does not exist", chartPath) + } + lock, err := chartutils.ReadLockFromChart(chartPath) + if err != nil { + return fmt.Errorf("failed to load Images.lock: %v", err) + } + if yamlFormat { + if err := lock.ToYAML(os.Stdout); err != nil { + return fmt.Errorf("failed to write Images.lock yaml representation: %v", err) + } + } else { + + _ = l.Section("Wrap Information", func(l log.SectionLogger) error { + l.Printf("Chart: %s", lock.Chart.Name) + l.Printf("Version: %s", lock.Chart.Version) + l.Printf("App Version: %s", lock.Chart.AppVersion) + _ = l.Section("Metadata", func(l log.SectionLogger) error { + for k, v := range lock.Metadata { + l.Printf("- %s: %s", k, v) + + } + return nil + }) + _ = l.Section("Images", func(l log.SectionLogger) error { + for _, img := range lock.Images { + if showDetails { + _ = l.Section(fmt.Sprintf("%s/%s", img.Chart, img.Name), func(l log.SectionLogger) error { + l.Printf("Image: %s", img.Image) + if showDetails { + l.Printf("Digests") + for _, digest := range img.Digests { + l.Printf("- Arch: %s", digest.Arch) + l.Printf(" Digest: %s", digest.Digest) + } + } + return nil + }) + } else { + platforms := make([]string, 0) + for _, digest := range img.Digests { + platforms = append(platforms, digest.Arch) + } + l.Printf("%s (%s)", img.Image, strings.Join(platforms, ", ")) + } + } + return nil + }) + return nil + }) + } + return nil + }, + } + cmd.PersistentFlags().BoolVar(&yamlFormat, "yaml", yamlFormat, "Show report in YAML format") + cmd.PersistentFlags().BoolVar(&showDetails, "detailed", showDetails, "When using the printable report, add more details about the bundled images") + + return cmd +} diff --git a/cmd/dt/info_test.go b/cmd/dt/info_test.go new file mode 100644 index 0000000..901cab6 --- /dev/null +++ b/cmd/dt/info_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +func (suite *CmdSuite) TestInfoCommand() { + + t := suite.T() + require := suite.Require() + assert := suite.Assert() + + sb := suite.sb + + t.Run("Get Wrap Info", func(t *testing.T) { + imageName := "test" + imageTag := "mytag" + + serverURL := "localhost" + scenarioName := "complete-chart" + chartName := "test" + version := "1.0.0" + appVersion := "2.3.4" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + chartDir := sb.TempFile() + + images, err := writeSampleImages(imageName, imageTag, filepath.Join(chartDir, "images")) + require.NoError(err) + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version, "AppVersion": appVersion, "RepositoryURL": serverURL}, + )) + + tarFile := sb.TempFile() + if err := utils.Tar(chartDir, tarFile, utils.TarConfig{ + Prefix: chartName, + }); err != nil { + require.NoError(err) + } + for _, inputChart := range []string{tarFile, chartDir} { + t.Run("Short info", func(t *testing.T) { + var archList []string + for _, digest := range images[0].Digests { + archList = append(archList, digest.Arch) + } + + res := dt("info", inputChart) + res.AssertSuccess(t) + imageURL := fmt.Sprintf("%s/%s:%s", serverURL, imageName, imageTag) + + imageEntryRe := fmt.Sprintf(`%s\s+\(%s\)`, imageURL, strings.Join(archList, ", ")) + assert.Regexp(fmt.Sprintf(`(?s).*Wrap Information.*Chart:.*%s\s*.*Version:.*%s.*%s\s*.*Metadata.*Images.*%s`, chartName, version, appVersion, imageEntryRe), res.stdout) + }) + t.Run("Detailed info", func(t *testing.T) { + res := dt("info", "--detailed", inputChart) + res.AssertSuccess(t) + imageURL := fmt.Sprintf("%s/%s:%s", serverURL, imageName, imageTag) + + imgDetailedInfo := fmt.Sprintf(`%s/%s.*Image:\s+%s.*Digests.*`, chartName, imageName, imageURL) + for _, digest := range images[0].Digests { + imgDetailedInfo += fmt.Sprintf(`.*- Arch:\s+%s.*Digest:\s+%s.*`, digest.Arch, digest.Digest) + } + assert.Regexp(fmt.Sprintf(`(?s).*Wrap Information.*Chart:.*%s\s*.*Version:.*%s.*Metadata.*Images.*%s`, chartName, version, imgDetailedInfo), res.stdout) + }) + t.Run("YAML format", func(t *testing.T) { + res := dt("info", "--yaml", inputChart) + res.AssertSuccess(t) + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version, "AppVersion": appVersion}, + ) + require.NoError(err) + + lockFileData, err := tu.NormalizeYAML(data) + require.NoError(err) + yamlInfoData, err := tu.NormalizeYAML(res.stdout) + require.NoError(err) + + assert.Equal(lockFileData, yamlInfoData) + + }) + + } + }) + t.Run("Errors", func(t *testing.T) { + serverURL := "localhost" + scenarioName := "plain-chart" + chartName := "test" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL}, + )) + + tarFile := sb.TempFile() + if err := utils.Tar(chartDir, tarFile, utils.TarConfig{ + Prefix: chartName, + }); err != nil { + require.NoError(err) + } + for _, inputChart := range []string{tarFile, chartDir} { + t.Run("Fails when missing Images.lock", func(t *testing.T) { + dt("info", inputChart).AssertErrorMatch(t, "failed to load Images.lock") + }) + } + t.Run("Handles non-existent wraps", func(t *testing.T) { + dt("info", sb.TempFile()).AssertErrorMatch(t, `wrap file.* does not exist`) + }) + }) +} diff --git a/cmd/dt/lock/lock.go b/cmd/dt/lock/lock.go new file mode 100644 index 0000000..9b14a27 --- /dev/null +++ b/cmd/dt/lock/lock.go @@ -0,0 +1,91 @@ +// Package lock implements the command to create the Images.lock file +package lock + +import ( + "bytes" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" +) + +// NewCmd returns a new dt lock command +func NewCmd(cfg *config.Config) *cobra.Command { + var platforms []string + var outputFile string + getOutputFilename := func(chartPath string) (string, error) { + if outputFile != "" { + return outputFile, nil + } + return chartutils.GetImageLockFilePath(chartPath) + } + + cmd := &cobra.Command{ + Use: "lock CHART_PATH", + Short: "Creates the lock file", + Long: "Creates the Images.lock file for the given Helm chart associating all the images at the time of locking", + Example: ` # Create the Images.lock for a Helm Chart + $ dt images lock examples/mariadb + + # Create the Images.lock from a Helm chart that uses a different annotation for specifying images + $ dt images lock examples/mariadb --annotations-key artifacthub.io/images`, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + l := cfg.Logger() + + chartPath := args[0] + + outputFile, err := getOutputFilename(chartPath) + if err != nil { + return fmt.Errorf("failed to obtain Images.lock location: %w", err) + } + if err := l.ExecuteStep("Generating Images.lock from annotations...", func() error { + return Create(chartPath, outputFile, silent.NewLogger(), imagelock.WithPlatforms(platforms), + imagelock.WithAnnotationsKey(cfg.AnnotationsKey), + imagelock.WithInsecure(cfg.Insecure)) + }); err != nil { + return l.Failf("Failed to genereate lock: %w", err) + } + l.Successf("Images.lock file written to %q", outputFile) + return nil + }, + } + cmd.PersistentFlags().StringVar(&outputFile, "output-file", outputFile, "output file where to write the Images Lock. If empty, writes to stdout") + cmd.PersistentFlags().StringSliceVar(&platforms, "platforms", platforms, "platforms to include in the Images.lock file") + + return cmd +} + +// Create generates an Images.lock file from the chart annotations +func Create(chartPath string, outputFile string, l log.Logger, opts ...imagelock.Option) error { + l.Infof("Generating images lock for Helm chart %q", chartPath) + + lock, err := imagelock.GenerateFromChart(chartPath, opts...) + + if err != nil { + return fmt.Errorf("failed to load Helm chart: %v", err) + } + + if len(lock.Images) == 0 { + l.Warnf("Did not find any image annotations at Helm chart %q", chartPath) + } + + buff := &bytes.Buffer{} + if err = lock.ToYAML(buff); err != nil { + return fmt.Errorf("failed to write Images.lock file: %v", err) + } + + if err := os.WriteFile(outputFile, buff.Bytes(), 0666); err != nil { + return fmt.Errorf("failed to write lock to %q: %w", outputFile, err) + } + + l.Infof("Images.lock file written to %q", outputFile) + return nil +} diff --git a/cmd/dt/lock_test.go b/cmd/dt/lock_test.go new file mode 100644 index 0000000..14b0c83 --- /dev/null +++ b/cmd/dt/lock_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "gopkg.in/yaml.v3" +) + +func (suite *CmdSuite) TestLockCommand() { + require := suite.Require() + + s, err := tu.NewTestServer() + require.NoError(err) + + defer s.Close() + + images, err := s.LoadImagesFromFile("../../testdata/images.json") + require.NoError(err) + + sb := suite.sb + serverURL := s.ServerURL + scenarioName := "custom-chart" + chartName := "test" + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + t := suite.T() + + t.Run("Generate lock file", func(t *testing.T) { + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, + )) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, + ) + require.NoError(err) + var expectedLock map[string]interface{} + require.NoError(yaml.Unmarshal([]byte(data), &expectedLock)) + + // Clear the timestamp + expectedLock["metadata"] = nil + + // We need to provide the --insecure flag or our test server won't validate + args := []string{"images", "lock", "--insecure", chartDir} + res := dt(args...) + res.AssertSuccess(t) + + newData, err := os.ReadFile(filepath.Join(chartDir, "Images.lock")) + require.NoError(err) + var newLock map[string]interface{} + require.NoError(yaml.Unmarshal(newData, &newLock)) + // Clear the timestamp + newLock["metadata"] = nil + + require.Equal(expectedLock, newLock) + + }) + t.Run("Errors", func(t *testing.T) { + t.Run("Handles failure to write lock because of permissions", func(t *testing.T) { + scenarioName := "plain-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{}, + )) + + require.NoError(os.Chmod(chartDir, os.FileMode(0555))) + defer os.Chmod(chartDir, os.FileMode(0755)) + + args := []string{"images", "lock", "--insecure", chartDir} + res := dt(args...) + res.AssertErrorMatch(t, "Failed to genereate lock: failed to write lock") + }) + t.Run("Handles non-existent chart", func(t *testing.T) { + args := []string{"images", "lock", sb.TempFile()} + res := dt(args...) + res.AssertErrorMatch(t, "failed to obtain Images.lock location: cannot access path") + }) + }) +} diff --git a/cmd/dt/login/login.go b/cmd/dt/login/login.go new file mode 100644 index 0000000..9b87ddd --- /dev/null +++ b/cmd/dt/login/login.go @@ -0,0 +1,98 @@ +// Package login implements the command to login to OCI registries +package login + +import ( + "io" + "os" + "strings" + + dockercfg "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/types" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" +) + +type loginOptions struct { + serverAddress string + user string + password string + passwordStdin bool +} + +// NewCmd returns a new dt login command +func NewCmd(cfg *config.Config) *cobra.Command { + var opts loginOptions + + cmd := &cobra.Command{ + Use: "login REGISTRY", + Short: "Log in to an OCI registry (Experimental)", + Long: "Experimental. Log in to an OCI registry using the Docker configuration file", + Example: ` # Log in to index.docker.io + $ dt auth login index.docker.io -u my_username -p my_password + + # Log in to index.docker.io with a password from stdin + $ dt auth login index.docker.io -u my_username --password-stdin < <(echo my_password)`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + l := cfg.Logger() + + reg, err := name.NewRegistry(args[0]) + if err != nil { + return l.Failf("failed to load registry %s: %v", args[0], err) + } + opts.serverAddress = reg.Name() + + return login(opts, l) + }, + } + + flags := cmd.Flags() + + flags.StringVarP(&opts.user, "username", "u", "", "Username") + flags.StringVarP(&opts.password, "password", "p", "", "Password") + flags.BoolVarP(&opts.passwordStdin, "password-stdin", "", false, "Take the password from stdin") + + return cmd +} + +// from https://github.com/google/go-containerregistry/blob/main/cmd/crane/cmd/auth.go +func login(opts loginOptions, l log.SectionLogger) error { + if opts.passwordStdin { + contents, err := io.ReadAll(os.Stdin) + if err != nil { + return l.Failf("failed to read from stdin: %v", err) + } + + opts.password = strings.TrimRight(string(contents), "\r\n") + } + if opts.user == "" && opts.password == "" { + return l.Failf("username and password required") + } + l.Infof("log in to %s as user %s", opts.serverAddress, opts.user) + cf, err := dockercfg.Load(os.Getenv("DOCKER_CONFIG")) + if err != nil { + return l.Failf("failed to load configuration: %v", err) + } + creds := cf.GetCredentialsStore(opts.serverAddress) + if opts.serverAddress == name.DefaultRegistry { + opts.serverAddress = authn.DefaultAuthKey + } + if err := creds.Store(types.AuthConfig{ + ServerAddress: opts.serverAddress, + Username: opts.user, + Password: opts.password, + }); err != nil { + return l.Failf("failed to store credentials: %v", err) + } + + if err := cf.Save(); err != nil { + return l.Failf("failed to save authorization information: %v", err) + } + l.Successf("logged in via %s", cf.Filename) + return nil +} diff --git a/cmd/dt/logout/logout.go b/cmd/dt/logout/logout.go new file mode 100644 index 0000000..e6e6182 --- /dev/null +++ b/cmd/dt/logout/logout.go @@ -0,0 +1,62 @@ +// Package logout implements the command to logout from OCI registries +package logout + +import ( + "os" + + dockercfg "github.com/docker/cli/cli/config" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" +) + +// NewCmd returns a new dt logout command +func NewCmd(cfg *config.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "logout REGISTRY", + Short: "Logout from an OCI registry (Experimental)", + Long: "Experimental. Logout from an OCI registry using the Docker configuration file", + Args: cobra.ExactArgs(1), + Example: ` # Log out from index.docker.io + $ dt auth logout index.docker.io`, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + l := cfg.Logger() + + reg, err := name.NewRegistry(args[0]) + if err != nil { + return l.Failf("failed to load registry %s: %v", args[0], err) + } + serverAddress := reg.Name() + + return logout(serverAddress, l) + }, + } + + return cmd +} + +// from https://github.com/google/go-containerregistry/blob/main/cmd/crane/cmd/auth.go +func logout(serverAddress string, l log.SectionLogger) error { + l.Infof("logout from %s", serverAddress) + cf, err := dockercfg.Load(os.Getenv("DOCKER_CONFIG")) + if err != nil { + return l.Failf("failed to load configuration: %v", err) + } + creds := cf.GetCredentialsStore(serverAddress) + if serverAddress == name.DefaultRegistry { + serverAddress = authn.DefaultAuthKey + } + if err := creds.Erase(serverAddress); err != nil { + return l.Failf("failed to store credentials: %v", err) + } + + if err := cf.Save(); err != nil { + return l.Failf("failed to save authorization information: %v", err) + } + l.Successf("logged out via %s", cf.Filename) + return nil +} diff --git a/cmd/dt/pull/pull.go b/cmd/dt/pull/pull.go new file mode 100644 index 0000000..56bf30b --- /dev/null +++ b/cmd/dt/pull/pull.go @@ -0,0 +1,122 @@ +// Package pull implements the pull command +package pull + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/wrapping" +) + +// ChartImages pulls the images of a Helm chart +func ChartImages(wrap wrapping.Wrap, imagesDir string, opts ...chartutils.Option) error { + return pullImages(wrap, imagesDir, opts...) +} + +// NewCmd builds a new pull command +func NewCmd(cfg *config.Config) *cobra.Command { + var outputFile string + var imagesDir string + + cmd := &cobra.Command{ + Use: "pull CHART_PATH", + Short: "Pulls the images from the Images.lock", + Long: "Pulls all the images that are defined within the Images.lock from the given Helm chart", + Example: ` # Pull images from a Helm Chart in a local folder + $ dt images pull examples/mariadb`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + chartPath := args[0] + l := cfg.Logger() + + // TODO: Implement timeout + + ctx, cancel := cfg.ContextWithSigterm() + defer cancel() + + chart, err := chartutils.LoadChart(chartPath) + if err != nil { + return fmt.Errorf("failed to load chart: %w", err) + } + if imagesDir == "" { + imagesDir = chart.ImagesDir() + } + lock, err := chart.GetImagesLock() + if err != nil { + return l.Failf("Failed to load Images.lock: %v", err) + } + if len(lock.Images) == 0 { + l.Warnf("No images found in Images.lock") + } else { + if err := l.Section(fmt.Sprintf("Pulling images into %q", chart.ImagesDir()), func(childLog log.SectionLogger) error { + if err := pullImages( + chart, + imagesDir, + chartutils.WithLog(childLog), + chartutils.WithContext(ctx), + chartutils.WithProgressBar(childLog.ProgressBar()), + chartutils.WithArtifactsDir(chart.ImageArtifactsDir()), + ); err != nil { + return childLog.Failf("%v", err) + } + childLog.Infof("All images pulled successfully") + return nil + }); err != nil { + return l.Failf("%w", err) + } + } + + if outputFile != "" { + if err := l.ExecuteStep( + fmt.Sprintf("Compressing chart into %q", outputFile), + func() error { + return utils.TarContext(ctx, chart.RootDir(), outputFile, utils.TarConfig{ + Prefix: fmt.Sprintf("%s-%s", chart.Name(), chart.Version()), + }) + }, + ); err != nil { + return l.Failf("failed to compress chart: %w", err) + } + + l.Infof("Helm chart compressed to %q", outputFile) + } + + var successMessage string + if outputFile != "" { + successMessage = fmt.Sprintf("All images pulled successfully and chart compressed into %q", outputFile) + } else { + successMessage = fmt.Sprintf("All images pulled successfully into %q", chart.ImagesDir()) + } + + l.Printf(widgets.TerminalSpacer) + l.Successf(successMessage) + + return nil + }, + } + cmd.PersistentFlags().StringVar(&outputFile, "output-file", outputFile, "generate a tar.gz with the output of the pull operation") + cmd.PersistentFlags().StringVar(&imagesDir, "images-dir", imagesDir, + "directory where the images will be pulled to. If not empty, it overrides the default images directory inside the chart directory") + return cmd +} + +func pullImages(chart wrapping.Lockable, imagesDir string, opts ...chartutils.Option) error { + lock, err := chart.GetImagesLock() + + if err != nil { + return fmt.Errorf("failed to read Images.lock file") + } + if err := chartutils.PullImages(lock, imagesDir, + opts..., + ); err != nil { + return fmt.Errorf("failed to pull images: %v", err) + } + return nil +} diff --git a/cmd/dt/pull_test.go b/cmd/dt/pull_test.go new file mode 100644 index 0000000..e42f4b9 --- /dev/null +++ b/cmd/dt/pull_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/registry" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +func (suite *CmdSuite) TestPullCommand() { + t := suite.T() + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + + imageName := "test:mytag" + + images, err := tu.AddSampleImagesToRegistry(imageName, u.Host) + if err != nil { + t.Fatal(err) + } + + sb := suite.sb + require := suite.Require() + serverURL := u.Host + scenarioName := "complete-chart" + chartName := "test" + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + createSampleChart := func(chartDir string) string { + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, + )) + return chartDir + } + verifyChartDir := func(chartDir string) { + imagesDir := filepath.Join(chartDir, "images") + suite.Require().DirExists(imagesDir) + for _, imgData := range images { + for _, digestData := range imgData.Digests { + imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", digestData.Digest.Encoded())) + suite.Assert().DirExists(imgDir) + } + } + } + t.Run("Pulls images", func(t *testing.T) { + chartDir := createSampleChart(sb.TempFile()) + dt("images", "pull", chartDir).AssertSuccessMatch(t, "") + verifyChartDir(chartDir) + }) + t.Run("Pulls images and compress into filename", func(t *testing.T) { + chartDir := createSampleChart(sb.TempFile()) + outputFile := fmt.Sprintf("%s.tar.gz", sb.TempFile()) + dt("images", "pull", "--output-file", outputFile, chartDir).AssertSuccess(t) + + tmpDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(err) + + require.NoError(utils.Untar(outputFile, tmpDir, utils.TarConfig{StripComponents: 1})) + + verifyChartDir(tmpDir) + }) + + t.Run("Warning when no images in Images.lock", func(t *testing.T) { + images = []tu.ImageData{} + scenarioName := "no-images-chart" + scenarioDir = fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + chartDir := createSampleChart(sb.TempFile()) + dt("images", "pull", chartDir).AssertSuccessMatch(t, "No images found in Images.lock") + require.NoDirExists(filepath.Join(chartDir, "images")) + }) + + t.Run("Errors", func(t *testing.T) { + t.Run("Fails when Images.lock is not found", func(t *testing.T) { + chartDir := createSampleChart(sb.TempFile()) + require.NoError(os.RemoveAll(filepath.Join(chartDir, "Images.lock"))) + + dt("images", "pull", chartDir).AssertErrorMatch(t, `Failed to load Images\.lock.*`) + }) + }) +} diff --git a/cmd/dt/push/push.go b/cmd/dt/push/push.go new file mode 100644 index 0000000..479d051 --- /dev/null +++ b/cmd/dt/push/push.go @@ -0,0 +1,87 @@ +// Package push implements the `dt images push` command +package push + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/wrapping" +) + +// ChartImages pushes the images from the Images.lock +func ChartImages(wrap wrapping.Wrap, imagesDir string, opts ...chartutils.Option) error { + return pushImages(wrap, imagesDir, opts...) +} + +func pushImages(wrap wrapping.Wrap, imagesDir string, opts ...chartutils.Option) error { + lock, err := wrap.GetImagesLock() + if err != nil { + return fmt.Errorf("failed to load Images.lock: %v", err) + } + + return chartutils.PushImages(lock, imagesDir, opts...) +} + +// NewCmd builds a new push command +func NewCmd(cfg *config.Config) *cobra.Command { + var imagesDir string + + cmd := &cobra.Command{ + Use: "push CHART_PATH", + Short: "Pushes the images from Images.lock", + Long: "Pushes the images found on the Images.lock from the given Helm chart path into their current registries", + Example: ` # Push images from a sample local Helm chart + # 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 + $ dt images push examples/mariadb`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + l := cfg.Logger() + + chartPath := args[0] + + ctx, cancel := cfg.ContextWithSigterm() + defer cancel() + + chart, err := chartutils.LoadChart(chartPath) + if err != nil { + return fmt.Errorf("failed to load chart: %w", err) + } + + if imagesDir == "" { + imagesDir = chart.ImagesDir() + } + if err := l.Section("Pushing Images", func(subLog log.SectionLogger) error { + if err := pushImages( + chart, + imagesDir, + chartutils.WithLog(silent.NewLogger()), + chartutils.WithContext(ctx), + chartutils.WithProgressBar(subLog.ProgressBar()), + chartutils.WithArtifactsDir(chart.ImageArtifactsDir()), + chartutils.WithInsecureMode(cfg.Insecure), + ); err != nil { + return subLog.Failf("Failed to push images: %w", err) + } + subLog.Infof("Images pushed successfully") + return nil + }); err != nil { + return err + } + + l.Printf(widgets.TerminalSpacer) + l.Successf("All images pushed successfully") + return nil + }, + } + cmd.PersistentFlags().StringVar(&imagesDir, "images-dir", imagesDir, + "directory containing the images to push. If not empty, it overrides the default images directory inside the chart directory") + return cmd +} diff --git a/cmd/dt/push_test.go b/cmd/dt/push_test.go new file mode 100644 index 0000000..36b2498 --- /dev/null +++ b/cmd/dt/push_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/registry" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" +) + +func (suite *CmdSuite) TestPushCommand() { + t := suite.T() + sb := suite.sb + require := suite.Require() + assert := suite.Assert() + + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + serverURL := u.Host + + t.Run("Handle errors", func(t *testing.T) { + t.Run("Handle missing Images.lock", func(t *testing.T) { + chartName := "test" + scenarioName := "plain-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, "Images": nil, + "Name": chartName, "RepositoryURL": serverURL, + }, + )) + dt("images", "push", chartDir).AssertErrorMatch(t, regexp.MustCompile(`failed to open Images.lock file:.*no such file or directory`)) + }) + t.Run("Handle malformed Helm chart", func(t *testing.T) { + dt("images", "push", sb.TempFile()).AssertErrorMatch(t, regexp.MustCompile(`failed to load Helm chart`)) + }) + t.Run("Handle malformed Images.lock", func(t *testing.T) { + chartName := "test" + scenarioName := "plain-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, "Images": nil, + "Name": chartName, "RepositoryURL": serverURL, + }, + )) + require.NoError(os.WriteFile(filepath.Join(chartDir, imagelock.DefaultImagesLockFileName), []byte("malformed lock"), 0644)) + dt("images", "push", chartDir).AssertErrorMatch(t, regexp.MustCompile(`failed to load Images.lock`)) + }) + t.Run("Handle failing to push images", func(t *testing.T) { + chartName := "test" + scenarioName := "chart1" + serverURL := "example.com" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, "Images": nil, + "Name": chartName, "RepositoryURL": serverURL, + }, + )) + dt("images", "push", chartDir).AssertErrorMatch(t, regexp.MustCompile(`(?i)failed to push images`)) + }) + }) + t.Run("Pushing works", func(t *testing.T) { + scenarioName := "complete-chart" + chartName := "test" + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + imageData := tu.ImageData{Name: "test", Image: "test:mytag"} + architectures := []string{ + "linux/amd64", + "linux/arm", + } + craneImgs, err := tu.CreateSampleImages(&imageData, architectures) + + if err != nil { + t.Fatal(err) + } + + require.Equal(len(architectures), len(imageData.Digests)) + + images := []tu.ImageData{imageData} + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, "Images": images, + "Name": chartName, "RepositoryURL": serverURL, + }, + )) + + imagesDir := filepath.Join(chartDir, "images") + require.NoError(os.MkdirAll(imagesDir, 0755)) + for _, img := range craneImgs { + d, err := img.Digest() + if err != nil { + t.Fatal(err) + } + imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", d.Hex)) + if err := crane.SaveOCI(img, imgDir); err != nil { + t.Fatal(err) + } + } + + t.Run("Push images", func(t *testing.T) { + require.NoError(err) + dt("images", "push", chartDir).AssertSuccessMatch(t, "") + + // Verify the images were pushed + for _, img := range images { + src := fmt.Sprintf("%s/%s", u.Host, img.Image) + remoteDigests, err := tu.ReadRemoteImageManifest(src) + if err != nil { + t.Fatal(err) + } + for _, dgstData := range img.Digests { + assert.Equal(dgstData.Digest.Hex(), remoteDigests[dgstData.Arch].Digest.Hex()) + } + } + }) + }) + +} diff --git a/cmd/dt/relocate/relocate.go b/cmd/dt/relocate/relocate.go new file mode 100644 index 0000000..654dde5 --- /dev/null +++ b/cmd/dt/relocate/relocate.go @@ -0,0 +1,51 @@ +// Package relocate implements the dt relocate command +package relocate + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/relocator" +) + +// NewCmd builds a new relocate command +func NewCmd(cfg *config.Config) *cobra.Command { + valuesFiles := []string{"values.yaml"} + cmd := &cobra.Command{ + Use: "relocate CHART_PATH OCI_URI", + Short: "Relocates a Helm chart", + 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", + Example: ` # Relocate a chart from DockerHub into demo Harbor + $ dt charts relocate examples/mariadb oci://demo.goharbor.io/test_repo`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + chartPath, repository := args[0], args[1] + if repository == "" { + return fmt.Errorf("repository cannot be empty") + } + l := cfg.Logger() + + if err := l.ExecuteStep(fmt.Sprintf("Relocating %q with prefix %q", chartPath, repository), func() error { + return relocator.RelocateChartDir( + chartPath, + repository, + relocator.WithLog(l), relocator.Recursive, + relocator.WithAnnotationsKey(cfg.AnnotationsKey), + relocator.WithValuesFiles(valuesFiles...), + ) + }); err != nil { + return l.Failf("failed to relocate Helm chart %q: %w", chartPath, err) + } + + l.Successf("Helm chart relocated successfully") + return nil + }, + } + + cmd.PersistentFlags().StringSliceVar(&valuesFiles, "values", valuesFiles, "values files to relocate images (can specify multiple)") + + return cmd +} diff --git a/cmd/dt/relocate_test.go b/cmd/dt/relocate_test.go new file mode 100644 index 0000000..681e2f8 --- /dev/null +++ b/cmd/dt/relocate_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "gopkg.in/yaml.v3" +) + +func readYamlFile(f string) (map[string]interface{}, error) { + var data map[string]interface{} + fh, err := os.Open(f) + if err != nil { + return nil, err + } + defer fh.Close() + dec := yaml.NewDecoder(fh) + err = dec.Decode(&data) + return data, err +} + +func (suite *CmdSuite) TestRelocateCommand() { + s, err := tu.NewTestServer() + suite.Require().NoError(err) + defer s.Close() + + images, err := s.LoadImagesFromFile("../../testdata/images.json") + suite.Require().NoError(err) + + sb := suite.sb + require := suite.Require() + serverURL := s.ServerURL + scenarioName := "custom-chart" + chartName := "test" + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + renderLockedChart := func(chartDir string, _ string, serverURL string) string { + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, + )) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, + ) + suite.Require().NoError(err) + suite.Require().NoError(os.WriteFile(filepath.Join(chartDir, "Images.lock"), []byte(data), 0644)) + return chartDir + } + suite.T().Run("Relocate Helm chart", func(_ *testing.T) { + relocateURL := "custom.repo.example.com" + originChart := renderLockedChart(sb.TempFile(), scenarioName, serverURL) + expectedRelocatedDir := renderLockedChart(sb.TempFile(), scenarioName, relocateURL) + cmd := dt("charts", "relocate", originChart, relocateURL) + cmd.AssertSuccess(suite.T()) + + for _, tail := range []string{"Chart.yaml", "Images.lock"} { + got, err := readYamlFile(filepath.Join(originChart, tail)) + suite.Require().NoError(err) + expected, err := readYamlFile(filepath.Join(expectedRelocatedDir, tail)) + suite.Require().NoError(err) + suite.Assert().Equal(expected, got) + } + }) +} diff --git a/cmd/dt/root.go b/cmd/dt/root.go new file mode 100644 index 0000000..875eabc --- /dev/null +++ b/cmd/dt/root.go @@ -0,0 +1,44 @@ +package main + +import ( + "path/filepath" + + "os" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/info" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/unwrap" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/wrap" +) + +var rootCmd = newRootCmd() + +var mainConfig = config.NewConfig() + +func newRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: filepath.Base(os.Args[0]), + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, + } + cmd.PersistentFlags().BoolVar(&mainConfig.Insecure, "insecure", mainConfig.Insecure, "skip TLS verification") + cmd.PersistentFlags().BoolVar(&mainConfig.UsePlainHTTP, "use-plain-http", mainConfig.UsePlainHTTP, "use plain HTTP when pulling and pushing charts") + cmd.PersistentFlags().StringVar(&mainConfig.AnnotationsKey, "annotations-key", mainConfig.AnnotationsKey, "annotation key used to define the list of included images") + + cmd.PersistentFlags().StringVar(&mainConfig.LogLevel, "log-level", mainConfig.LogLevel, "set log level: (trace, debug, info, warn, error, fatal, panic)") + cmd.PersistentFlags().BoolVar(&mainConfig.UsePlainLog, "plain", mainConfig.UsePlainLog, "suppress the progress bar and symbols in messages and display only plain log messages") + cmd.PersistentFlags().BoolVar(&config.KeepArtifacts, "keep-artifacts", config.KeepArtifacts, "keep temporary artifacts created during the tool execution") + + // Do not show completion command + cmd.CompletionOptions.DisableDefaultCmd = true + + cmd.AddCommand(authCmd) + cmd.AddCommand(chartCmd) + cmd.AddCommand(imagesCmd) + cmd.AddCommand(versionCmd) + cmd.AddCommand(wrap.NewCmd(mainConfig), unwrap.NewCmd(mainConfig), info.NewCmd(mainConfig)) + + return cmd +} diff --git a/cmd/dt/unwrap/unwrap.go b/cmd/dt/unwrap/unwrap.go new file mode 100644 index 0000000..d1c87c7 --- /dev/null +++ b/cmd/dt/unwrap/unwrap.go @@ -0,0 +1,494 @@ +// Package unwrap implements the unwrap command +package unwrap + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/push" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/verify" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/wrap" + "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/logrus" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/relocator" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/wrapping" +) + +var ( + maxRetries = 3 +) + +// Config defines the configuration for the Wrap/Unwrap command +type Config struct { + Context context.Context + AnnotationsKey string + UsePlainHTTP bool + Insecure bool + Platforms []string + logger log.SectionLogger + TempDirectory string + Version string + Carvelize bool + KeepArtifacts bool + FetchArtifacts bool + Auth Auth + ContainerRegistryAuth Auth + ValuesFiles []string + + // Interactive enables interacting with the user + Interactive bool + SayYes bool +} + +// Auth defines the authentication information to access the container registry +type Auth struct { + Username string + Password string +} + +// WithAuth configures the Auth of the unwrap Config +func WithAuth(username, password string) func(c *Config) { + return func(c *Config) { + c.Auth = Auth{ + Username: username, + Password: password, + } + } +} + +// WithContainerRegistryAuth configures the ContainerRegistryAuth of the unwrap Config +func WithContainerRegistryAuth(username, password string) func(c *Config) { + return func(c *Config) { + c.ContainerRegistryAuth = Auth{ + Username: username, + Password: password, + } + } +} + +// WithSayYes configures the SayYes of the WrapConfig +func WithSayYes(sayYes bool) func(c *Config) { + return func(c *Config) { + c.SayYes = sayYes + } +} + +// WithKeepArtifacts configures the KeepArtifacts of the WrapConfig +func WithKeepArtifacts(keepArtifacts bool) func(c *Config) { + return func(c *Config) { + c.KeepArtifacts = keepArtifacts + } +} + +// WithInteractive configures the Interactive of the WrapConfig +func WithInteractive(interactive bool) func(c *Config) { + return func(c *Config) { + c.Interactive = interactive + } +} + +// ShouldFetchChartArtifacts returns true if the chart artifacts should be fetched +func (c *Config) ShouldFetchChartArtifacts(inputChart string) bool { + if chartutils.IsRemoteChart(inputChart) { + return c.FetchArtifacts + } + return false +} + +// Option defines a WrapOpts setting +type Option func(*Config) + +// WithInsecure configures the InsecureMode of the WrapConfig +func WithInsecure(insecure bool) func(c *Config) { + return func(c *Config) { + c.Insecure = insecure + } +} + +// WithUsePlainHTTP configures the UsePlainHTTP of the WrapConfig +func WithUsePlainHTTP(usePlainHTTP bool) func(c *Config) { + return func(c *Config) { + c.UsePlainHTTP = usePlainHTTP + } +} + +// WithAnnotationsKey configures the AnnotationsKey of the WrapConfig +func WithAnnotationsKey(annotationsKey string) func(c *Config) { + return func(c *Config) { + c.AnnotationsKey = annotationsKey + } +} + +// WithCarvelize configures the Carvelize of the WrapConfig +func WithCarvelize(carvelize bool) func(c *Config) { + return func(c *Config) { + c.Carvelize = carvelize + } +} + +// WithFetchArtifacts configures the FetchArtifacts of the WrapConfig +func WithFetchArtifacts(fetchArtifacts bool) func(c *Config) { + return func(c *Config) { + c.FetchArtifacts = fetchArtifacts + } +} + +// WithVersion configures the Version of the WrapConfig +func WithVersion(version string) func(c *Config) { + return func(c *Config) { + c.Version = version + } +} + +// WithLogger configures the Logger of the WrapConfig +func WithLogger(logger log.SectionLogger) func(c *Config) { + return func(c *Config) { + c.logger = logger + } +} + +// WithContext configures the Context of the WrapConfig +func WithContext(ctx context.Context) func(c *Config) { + return func(c *Config) { + c.Context = ctx + } +} + +// GetTemporaryDirectory returns the temporary directory of the WrapConfig +func (c *Config) GetTemporaryDirectory() (string, error) { + if c.TempDirectory != "" { + return c.TempDirectory, nil + } + return config.GetGlobalTempWorkDir() +} + +// GetLogger returns the logger of the WrapConfig +func (c *Config) GetLogger() log.SectionLogger { + if c.logger != nil { + return c.logger + } + return logrus.NewSectionLogger() +} + +// WithPlatforms configures the Platforms of the WrapConfig +func WithPlatforms(platforms []string) func(c *Config) { + return func(c *Config) { + c.Platforms = platforms + } +} + +// WithTempDirectory configures the TempDirectory of the WrapConfig +func WithTempDirectory(tempDir string) func(c *Config) { + return func(c *Config) { + c.TempDirectory = tempDir + } +} + +// WithValuesFiles configures the values files of the wrapped chart +func WithValuesFiles(files ...string) func(c *Config) { + return func(c *Config) { + c.ValuesFiles = files + } +} + +// NewConfig returns a new WrapConfig with default values +func NewConfig(opts ...Option) *Config { + cfg := &Config{ + Context: context.Background(), + TempDirectory: "", + logger: logrus.NewSectionLogger(), + AnnotationsKey: imagelock.DefaultAnnotationsKey, + Platforms: []string{}, + ValuesFiles: []string{"values.yaml"}, + } + + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// Chart unwraps a Helm chart +func Chart(inputChart, registryURL, pushChartURL string, opts ...Option) (string, error) { + return unwrapChart(inputChart, registryURL, pushChartURL, opts...) +} + +func askYesNoQuestion(msg string, cfg *Config) bool { + if cfg.SayYes { + return true + } + if !cfg.Interactive { + return false + } + return widgets.ShowYesNoQuestion(msg) +} + +func unwrapChart(inputChart, registryURL, pushChartURL string, opts ...Option) (string, error) { + + cfg := NewConfig(opts...) + + ctx := cfg.Context + parentLog := cfg.GetLogger() + + if registryURL == "" { + return "", fmt.Errorf("the registry cannot be empty") + } + tempDir, err := cfg.GetTemporaryDirectory() + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + + l := parentLog.StartSection(fmt.Sprintf("Unwrapping Helm chart %q", inputChart)) + + if cfg.KeepArtifacts { + l.Debugf("Temporary assets kept at %q", tempDir) + } + + chartPath, err := wrap.ResolveInputChartPath( + inputChart, + wrap.NewConfig( + wrap.WithTempDirectory(cfg.TempDirectory), + wrap.WithLogger(l), + wrap.WithVersion(cfg.Version), + wrap.WithInsecure(cfg.Insecure), + wrap.WithUsePlainHTTP(cfg.UsePlainHTTP), + ), + ) + if err != nil { + return "", err + } + + wrap, err := wrapping.Load(chartPath) + if err != nil { + return "", err + } + if err := l.ExecuteStep(fmt.Sprintf("Relocating %q with prefix %q", wrap.ChartDir(), registryURL), func() error { + return relocator.RelocateChartDir( + wrap.ChartDir(), registryURL, relocator.WithLog(l), + relocator.Recursive, relocator.WithAnnotationsKey(cfg.AnnotationsKey), relocator.WithValuesFiles(cfg.ValuesFiles...), + ) + }); err != nil { + return "", l.Failf("failed to relocate %q: %w", chartPath, err) + } + l.Infof("Helm chart relocated successfully") + + images := getImageList(wrap, l) + + if len(images) > 0 { + // If we are not in interactive mode, we do not show the list of images + if cfg.Interactive { + showImagesSummary(images, l) + } + if askYesNoQuestion(l.PrefixText("Do you want to push the wrapped images to the OCI registry?"), cfg) { + if err := l.Section("Pushing Images", func(subLog log.SectionLogger) error { + return pushChartImagesAndVerify(ctx, wrap, NewConfig(append(opts, WithLogger(subLog))...)) + }); err != nil { + return "", l.Failf("Failed to push images: %w", err) + } + l.Printf(widgets.TerminalSpacer) + } + } + + if askYesNoQuestion(l.PrefixText("Do you want to push the Helm chart to the OCI registry?"), cfg) { + if pushChartURL == "" { + pushChartURL = registryURL + // we will push the chart to the same registry as the containers + cfg.Auth = cfg.ContainerRegistryAuth + } + pushChartURL = normalizeOCIURL(pushChartURL) + fullChartURL := fmt.Sprintf("%s/%s", pushChartURL, wrap.Chart().Name()) + + if err := l.ExecuteStep(fmt.Sprintf("Pushing Helm chart to %q", pushChartURL), func() error { + return utils.ExecuteWithRetry(maxRetries, func(try int, prevErr error) error { + if try > 0 { + l.Debugf("Failed to push Helm chart: %v", prevErr) + } + return pushChart(ctx, wrap, pushChartURL, cfg) + }) + }); err != nil { + return "", l.Failf("Failed to push Helm chart: %w", err) + } + + l.Infof("Helm chart successfully pushed") + return fullChartURL, nil + } + return "", nil +} + +func pushChartImagesAndVerify(ctx context.Context, wrap wrapping.Wrap, cfg *Config) error { + lockFile := wrap.LockFilePath() + + l := cfg.GetLogger() + if !utils.FileExists(lockFile) { + return fmt.Errorf("lock file %q does not exist", lockFile) + } + if err := push.ChartImages( + wrap, + wrap.ImagesDir(), + chartutils.WithLog(silent.NewLogger()), + chartutils.WithContext(ctx), + chartutils.WithArtifactsDir(wrap.ImageArtifactsDir()), + chartutils.WithProgressBar(l.ProgressBar()), + chartutils.WithInsecureMode(cfg.Insecure), + chartutils.WithAuth(cfg.ContainerRegistryAuth.Username, cfg.ContainerRegistryAuth.Password), + ); err != nil { + return err + } + l.Infof("All images pushed successfully") + if err := l.ExecuteStep("Verifying Images.lock", func() error { + + return verify.Lock(wrap.ChartDir(), lockFile, verify.Config{ + Insecure: cfg.Insecure, AnnotationsKey: cfg.AnnotationsKey, + Auth: verify.Auth{Username: cfg.ContainerRegistryAuth.Username, Password: cfg.ContainerRegistryAuth.Password}, + }) + }); err != nil { + return fmt.Errorf("failed to verify Helm chart Images.lock: %w", err) + } + l.Infof("Chart %q lock is valid", wrap.ChartDir()) + return nil +} + +func getImageList(wrap wrapping.Lockable, l log.SectionLogger) imagelock.ImageList { + lock, err := wrap.GetImagesLock() + + if err != nil { + l.Debugf("failed to load list of images: failed to load lock file: %v", err) + return imagelock.ImageList{} + } + if len(lock.Images) == 0 { + l.Warnf("The bundle does not include any image") + return imagelock.ImageList{} + } + return lock.Images +} + +func showImagesSummary(images imagelock.ImageList, l log.SectionLogger) { + _ = l.Section(fmt.Sprintf("The wrap includes the following %d images:\n", len(images)), func(log.SectionLogger) error { + for _, img := range images { + l.Printf(img.Image) + } + l.Printf(widgets.TerminalSpacer) + return nil + }) +} + +func normalizeOCIURL(url string) string { + schemeRe := regexp.MustCompile(`([a-z][a-z0-9+\-.]*)://`) + if !schemeRe.MatchString(url) { + return fmt.Sprintf("oci://%s", url) + } + return url +} + +func pushChart(ctx context.Context, wrap wrapping.Wrap, pushChartURL string, cfg *Config) error { + chart := wrap.Chart() + chartPath := chart.RootDir() + tmpDir, err := cfg.GetTemporaryDirectory() + if err != nil { + return err + } + dir, err := os.MkdirTemp(tmpDir, "chart-*") + + if err != nil { + return fmt.Errorf("failed to upload Helm chart: failed to create temp directory: %w", err) + } + + tempTarFile := filepath.Join(dir, fmt.Sprintf("%s.tgz", chart.Name())) + if err := utils.Tar(chartPath, tempTarFile, utils.TarConfig{ + Prefix: chart.Name(), + }); err != nil { + return fmt.Errorf("failed to untar filename %q: %w", chartPath, err) + } + d, err := cfg.GetTemporaryDirectory() + if err != nil { + return fmt.Errorf("failed to get temp dir: %w", err) + } + if err := artifacts.PushChart(tempTarFile, pushChartURL, + artifacts.WithInsecure(cfg.Insecure), artifacts.WithPlainHTTP(cfg.UsePlainHTTP), + artifacts.WithRegistryAuth(cfg.Auth.Username, cfg.Auth.Password), + artifacts.WithTempDir(d), + ); err != nil { + return err + } + fullChartURL := fmt.Sprintf("%s/%s", pushChartURL, chart.Name()) + + metadataArtifactDir := filepath.Join(chart.RootDir(), artifacts.HelmChartArtifactMetadataDir) + if utils.FileExists(metadataArtifactDir) { + return artifacts.PushChartMetadata(ctx, fmt.Sprintf("%s:%s", fullChartURL, chart.Version()), metadataArtifactDir, artifacts.WithAuth(cfg.Auth.Username, cfg.Auth.Password)) + } + return nil +} + +// NewCmd returns a new unwrap command +func NewCmd(cfg *config.Config) *cobra.Command { + var ( + sayYes bool + pushChartURL string + version string + ) + valuesFiles := []string{"values.yaml"} + cmd := &cobra.Command{ + Use: "unwrap FILE OCI_URI", + Short: "Unwraps a wrapped Helm chart", + Long: "Unwraps a wrapped package and moves it into a target OCI registry. This command will read a wrap tarball and push all its container images and Helm chart into the target OCI registry", + Example: ` # Unwrap a Helm chart and push it into a Harbor repository + $ dt unwrap mariadb-12.2.8.wrap.tgz oci://demo.goharbor.io/test_repo +`, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + l := cfg.Logger() + + inputChart, registryURL := args[0], args[1] + ctx, cancel := cfg.ContextWithSigterm() + defer cancel() + + tempDir, err := cfg.GetTemporaryDirectory() + if err != nil { + return fmt.Errorf("failed to create temporary directory: %v", err) + } + fullChartURL, err := unwrapChart(inputChart, registryURL, pushChartURL, + WithLogger(l), + WithSayYes(sayYes), + WithContext(ctx), + WithVersion(version), + WithInteractive(true), + WithInsecure(cfg.Insecure), + WithTempDirectory(tempDir), + WithUsePlainHTTP(cfg.UsePlainHTTP), + WithValuesFiles(valuesFiles...), + ) + if err != nil { + return err + } + var successMessage = "Helm chart unwrapped successfully" + if fullChartURL != "" { + successMessage = fmt.Sprintf(`%s: You can use it now by running "helm install %s --generate-name"`, successMessage, fullChartURL) + } + l.Printf(widgets.TerminalSpacer) + l.Successf(successMessage) + return nil + }, + } + + cmd.PersistentFlags().StringVar(&version, "version", version, "when unwrapping remote Helm charts from OCI, version to request") + cmd.PersistentFlags().StringVar(&pushChartURL, "push-chart-url", pushChartURL, "push the unwrapped Helm chart to the given URL") + cmd.PersistentFlags().BoolVar(&sayYes, "yes", sayYes, "respond 'yes' to any yes/no question") + cmd.PersistentFlags().StringSliceVar(&valuesFiles, "values", valuesFiles, "values files to relocate images (can specify multiple)") + + return cmd +} diff --git a/cmd/dt/unwrap_test.go b/cmd/dt/unwrap_test.go new file mode 100644 index 0000000..7b46a77 --- /dev/null +++ b/cmd/dt/unwrap_test.go @@ -0,0 +1,429 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/unwrap" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/logrus" + + "helm.sh/helm/v3/pkg/repo/repotest" +) + +type unwrapOpts struct { + FetchedArtifacts bool + PublicKey string + Images []tu.ImageData + ChartName string + Version string + ArtifactsMetadata map[string][]byte + UseAPI bool + Auth tu.Auth + ContainerRegistryAuth tu.Auth +} + +func testChartUnwrap(t *testing.T, sb *tu.Sandbox, inputChart string, targetRegistry string, chartTargetRegistry string, srcRegistry string, cfg unwrapOpts) { + args := []string{"unwrap", "--log-level", "debug", "--plain", "--yes", "--use-plain-http", inputChart, targetRegistry} + craneAuth := authn.Anonymous + if cfg.ContainerRegistryAuth.Username != "" && cfg.ContainerRegistryAuth.Password != "" { + craneAuth = &authn.Basic{Username: cfg.ContainerRegistryAuth.Username, Password: cfg.ContainerRegistryAuth.Password} + } + if chartTargetRegistry == "" { + cfg.Auth = cfg.ContainerRegistryAuth + chartTargetRegistry = targetRegistry + } else { + args = append(args, "--push-chart-url", chartTargetRegistry) + } + if cfg.UseAPI { + l := logrus.NewSectionLogger() + l.SetWriter(io.Discard) + opts := []unwrap.Option{ + unwrap.WithLogger(l), + unwrap.WithUsePlainHTTP(true), + unwrap.WithSayYes(true), + unwrap.WithAuth(cfg.Auth.Username, cfg.Auth.Password), + unwrap.WithContainerRegistryAuth(cfg.ContainerRegistryAuth.Username, cfg.ContainerRegistryAuth.Password), + } + _, err := unwrap.Chart(inputChart, targetRegistry, chartTargetRegistry, opts...) + require.NoError(t, err) + } else { + dt(args...).AssertSuccessMatch(t, "") + } + assert.True(t, + artifacts.RemoteChartExist( + fmt.Sprintf("oci://%s/%s", chartTargetRegistry, cfg.ChartName), + cfg.Version, + artifacts.WithRegistryAuth(cfg.Auth.Username, cfg.Auth.Password), + artifacts.WithPlainHTTP(true), + ), + "chart should exist in the repository", + ) + + normalizedSrcRegistry := srcRegistry + if !strings.Contains(normalizedSrcRegistry, "://") { + normalizedSrcRegistry = "http://" + normalizedSrcRegistry + } + u, err := url.Parse(normalizedSrcRegistry) + require.NoError(t, err) + + path := u.Path + + relocatedRegistryPath := targetRegistry + if path != "" && path != "/" { + relocatedRegistryPath = fmt.Sprintf("%s/%s", relocatedRegistryPath, strings.Trim(filepath.Base(path), "/")) + + } + + // Verify the images were pushed + for _, img := range cfg.Images { + src := fmt.Sprintf("%s/%s", relocatedRegistryPath, img.Image) + remoteDigests, err := tu.ReadRemoteImageManifest(src, tu.WithAuth(cfg.ContainerRegistryAuth.Username, cfg.ContainerRegistryAuth.Password)) + if err != nil { + t.Fatal(err) + } + for _, dgstData := range img.Digests { + assert.Equal(t, dgstData.Digest.Hex(), remoteDigests[dgstData.Arch].Digest.Hex()) + } + + tagsInfo := map[string]string{"main": "", "metadata": ""} + tags, err := artifacts.ListTags(context.Background(), fmt.Sprintf("%s/%s", srcRegistry, "test"), crane.WithAuth(craneAuth)) + require.NoError(t, err) + + for _, tag := range tags { + if tag == "latest" { + tagsInfo["main"] = tag + } else if strings.HasSuffix(tag, ".metadata") { + tagsInfo["metadata"] = tag + } + } + for _, k := range []string{"main", "metadata"} { + v := tagsInfo[k] + if v == "" { + assert.Fail(t, "Tag %q should not be empty", k) + continue + } + if cfg.PublicKey != "" { + assert.NoError(t, tu.CosignVerifyImage(fmt.Sprintf("%s:%s", src, v), cfg.PublicKey, crane.WithAuth(craneAuth)), "Signature for %q failed", src) + } + } + // Verify the metadata + if cfg.FetchedArtifacts { + ociMetadataDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(t, err) + require.NoError(t, tu.PullArtifact(context.Background(), fmt.Sprintf("%s:%s", src, tagsInfo["metadata"]), ociMetadataDir, crane.WithAuth(craneAuth))) + + verifyArtifactsContents(t, sb, ociMetadataDir, cfg.ArtifactsMetadata) + } + } +} + +func writeSampleImages(imageName string, imageTag string, dir string) ([]tu.ImageData, error) { + _ = os.MkdirAll(dir, 0755) + fullImageName := fmt.Sprintf("%s:%s", imageName, imageTag) + imageData := tu.ImageData{Name: imageName, Image: fullImageName} + + craneImages, err := tu.CreateSampleImages(&imageData, []string{ + "linux/amd64", + "linux/arm64", + }) + + if err != nil { + return nil, err + } + + for _, img := range craneImages { + d, err := img.Digest() + if err != nil { + return nil, fmt.Errorf("failed to get image digest: %w", err) + } + + imgDir := filepath.Join(dir, fmt.Sprintf("%s.layout", d.Hex)) + + if err := crane.SaveOCI(img, imgDir); err != nil { + return nil, fmt.Errorf("failed to save image %q to %q: %w", fullImageName, imgDir, err) + } + + } + return []tu.ImageData{imageData}, nil +} + +func (suite *CmdSuite) TestUnwrapCommand() { + t := suite.T() + tests := []struct { + name string + auth bool + }{ + {name: "WithoutAuth", auth: false}, + {name: "WithAuth", auth: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var username, password string + var useAPI bool + var registryURL string + if tc.auth { + useAPI = true + + srv, err := repotest.NewTempServerWithCleanup(t, "") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + ociSrv, err := tu.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + go ociSrv.ListenAndServe() + registryURL = ociSrv.RegistryURL + + username = "username" + password = "password" + } else { + silentLog := log.New(io.Discard, "", 0) + + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + + registryURL = u.Host + } + + imageName := "test" + imageTag := "mytag" + + sb := suite.sb + serverURL := registryURL + scenarioName := "complete-chart" + chartName := "test" + version := "1.0.0" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + currentRegIdx := 0 + + newTargetRegistry := func(name string) string { + return fmt.Sprintf("%s/%s", serverURL, name) + } + newUniqueTargetRegistry := func() string { + currentRegIdx++ + return newTargetRegistry(fmt.Sprintf("new-images-%d", currentRegIdx)) + } + t.Run("Unwrap Chart", func(t *testing.T) { + require := suite.Require() + assert := suite.Assert() + + wrapDir := sb.TempFile() + + chartDir := filepath.Join(wrapDir, "chart") + + images, err := writeSampleImages(imageName, imageTag, filepath.Join(wrapDir, "images")) + require.NoError(err) + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version, "RepositoryURL": serverURL}, + )) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version}, + ) + require.NoError(err) + require.NoError(os.WriteFile(filepath.Join(chartDir, "Images.lock"), []byte(data), 0755)) + targetRegistry := newUniqueTargetRegistry() + args := []string{"unwrap", wrapDir, targetRegistry, "--plain", "--yes", "--use-plain-http"} + if useAPI { + l := logrus.NewSectionLogger() + l.SetWriter(io.Discard) + opts := []unwrap.Option{ + unwrap.WithLogger(l), + unwrap.WithUsePlainHTTP(true), + unwrap.WithSayYes(true), + unwrap.WithContainerRegistryAuth(username, password), + } + _, err := unwrap.Chart(wrapDir, targetRegistry, "", opts...) + require.NoError(err) + } else { + dt(args...).AssertSuccessMatch(suite.T(), "") + } + // Verify the images were pushed + for _, img := range images { + src := fmt.Sprintf("%s/%s", targetRegistry, img.Image) + remoteDigests, err := tu.ReadRemoteImageManifest(src, tu.WithAuth(username, password)) + if err != nil { + t.Fatal(err) + } + for _, dgstData := range img.Digests { + assert.Equal(dgstData.Digest.Hex(), remoteDigests[dgstData.Arch].Digest.Hex()) + } + } + assert.True( + artifacts.RemoteChartExist( + fmt.Sprintf("oci://%s/%s", targetRegistry, chartName), + version, + artifacts.WithRegistryAuth(username, password), + artifacts.WithPlainHTTP(true), + ), + "chart should exist in the repository", + ) + }) + }) + } +} +func (suite *CmdSuite) TestEndToEnd() { + t := suite.T() + tests := []struct { + name string + auth bool + }{ + {name: "WithoutAuth", auth: false}, + {name: "WithAuth", auth: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var contUser, contPass string + var username, password string + var useAPI bool + var registryURL string + var pushChartURL string + if tc.auth { + useAPI = true + contUser = "username" + contPass = "password" + + srv, err := repotest.NewTempServerWithCleanup(t, "") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + srv2, err := repotest.NewTempServerWithCleanup(t, "") + if err != nil { + t.Fatal(err) + } + defer srv2.Stop() + + contSrv, err := tu.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + go contSrv.ListenAndServe() + registryURL = contSrv.RegistryURL + + username = "username2" + password = "password2" + ociSrv, err := tu.NewOCIServerWithCustomCreds(t, srv2.Root(), username, password) + if err != nil { + t.Fatal(err) + } + go ociSrv.ListenAndServe() + pushChartURL = ociSrv.RegistryURL + } else { + silentLog := log.New(io.Discard, "", 0) + + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + registryURL = u.Host + } + imageName := "test" + + sb := suite.sb + serverURL := registryURL + scenarioName := "complete-chart" + chartName := "test" + version := "1.0.0" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + currentRegIdx := 0 + + newTargetRegistry := func(name string) string { + return fmt.Sprintf("%s/%s", serverURL, name) + } + newUniqueTargetRegistry := func() string { + currentRegIdx++ + return newTargetRegistry(fmt.Sprintf("new-images-%d", currentRegIdx)) + } + t.Run("Wrap and unwrap Chart", func(t *testing.T) { + require := suite.Require() + chartDir := sb.TempFile() + + srcRegistryNamespace := "wrap-unwrap-test" + srcRegistry := newTargetRegistry(srcRegistryNamespace) + targetRegistry := newUniqueTargetRegistry() + + certDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(err) + + keyFile, pubKey, err := tu.GenerateCosignCertificateFiles(certDir) + require.NoError(err) + + metadataDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(err) + + metdataFileText := "this is a sample text" + + metadataArtifacts := map[string][]byte{ + "metadata.txt": []byte(metdataFileText), + } + for fileName, data := range metadataArtifacts { + _, err := sb.Write(filepath.Join(metadataDir, fileName), string(data)) + require.NoError(err) + } + + images, err := tu.AddSampleImagesToRegistry(imageName, srcRegistry, tu.WithSignKey(keyFile), tu.WithMetadataDir(metadataDir), tu.WithAuth(contUser, contPass)) + if err != nil { + require.NoError(err) + } + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": srcRegistry, "Images": images, "Name": chartName, "Version": version, "RepositoryURL": srcRegistry}, + )) + + tempFilename := fmt.Sprintf("%s/chart.wrap.tar.gz", sb.TempFile()) + + testChartWrap(t, sb, chartDir, nil, wrapOpts{ + FetchArtifacts: true, + GenerateCarvelBundle: false, + ChartName: chartName, + Version: version, + OutputFile: tempFilename, + SkipExpectedLock: true, + Images: images, + ArtifactsMetadata: metadataArtifacts, + UseAPI: useAPI, + ContainerRegistryAuth: tu.Auth{Username: contUser, Password: contPass}, + }) + + testChartUnwrap(t, sb, tempFilename, targetRegistry, pushChartURL, srcRegistry, unwrapOpts{ + FetchedArtifacts: true, Images: images, PublicKey: pubKey, + ArtifactsMetadata: metadataArtifacts, + ChartName: chartName, + Version: version, + UseAPI: useAPI, + ContainerRegistryAuth: tu.Auth{Username: contUser, Password: contPass}, + Auth: tu.Auth{Username: username, Password: password}, + }) + }) + }) + } +} diff --git a/cmd/dt/verify/verify.go b/cmd/dt/verify/verify.go new file mode 100644 index 0000000..7c13c00 --- /dev/null +++ b/cmd/dt/verify/verify.go @@ -0,0 +1,103 @@ +// Package verify defines the verify command +package verify + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +// Auth defines the authentication information to access the container registry +type Auth struct { + Username string + Password string +} + +// Config defines the configuration of the verify command +type Config struct { + AnnotationsKey string + Insecure bool + Auth Auth +} + +// Lock verifies the images in an Images.lock +func Lock(chartPath string, lockFile string, cfg Config) error { + if !utils.FileExists(chartPath) { + return fmt.Errorf("Helm chart %q does not exist", chartPath) + } + fh, err := os.Open(lockFile) + if err != nil { + return fmt.Errorf("failed to open Images.lock file: %v", err) + } + defer fh.Close() + + currentLock, err := imagelock.FromYAML(fh) + if err != nil { + return fmt.Errorf("failed to load Images.lock: %v", err) + } + calculatedLock, err := imagelock.GenerateFromChart(chartPath, + imagelock.WithAnnotationsKey(cfg.AnnotationsKey), + imagelock.WithContext(context.Background()), + imagelock.WithAuth(cfg.Auth.Username, cfg.Auth.Password), + imagelock.WithInsecure(cfg.Insecure), + ) + + if err != nil { + return fmt.Errorf("failed to re-create Images.lock from Helm chart %q: %v", chartPath, err) + } + + if err := calculatedLock.Validate(currentLock.Images); err != nil { + return fmt.Errorf("Images.lock does not validate:\n%v", err) + } + return nil +} + +// NewCmd builds a new verify command +func NewCmd(cfg *config.Config) *cobra.Command { + var lockFile string + + cmd := &cobra.Command{ + Use: "verify CHART_PATH", + Short: "Verifies the images in an Images.lock", + 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", + Example: ` # Verifies integrity of the container images on the given Helm chart + $ dt images verify examples/mariadb`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, args []string) error { + chartPath := args[0] + + l := cfg.Logger() + + if !utils.FileExists(chartPath) { + return fmt.Errorf("Helm chart %q does not exist", chartPath) + } + + if lockFile == "" { + f, err := chartutils.GetImageLockFilePath(chartPath) + if err != nil { + return fmt.Errorf("failed to find Images.lock file for Helm chart %q: %v", chartPath, err) + } + lockFile = f + } + + if err := l.ExecuteStep("Verifying Images.lock", func() error { + return Lock(chartPath, lockFile, Config{Insecure: cfg.Insecure, AnnotationsKey: cfg.AnnotationsKey}) + }); err != nil { + return l.Failf("failed to verify %q lock: %w", chartPath, err) + } + + l.Successf("Helm chart %q lock is valid", chartPath) + return nil + }, + } + cmd.PersistentFlags().StringVar(&lockFile, "imagelock-file", lockFile, "location of the Images.lock YAML file") + return cmd +} diff --git a/cmd/dt/verify_test.go b/cmd/dt/verify_test.go new file mode 100644 index 0000000..1678567 --- /dev/null +++ b/cmd/dt/verify_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/opencontainers/go-digest" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" +) + +func (suite *CmdSuite) TestVerifyCommand() { + t := suite.T() + sb := suite.sb + require := suite.Require() + + s, err := tu.NewTestServer() + suite.Require().NoError(err) + + defer s.Close() + + serverURL := s.ServerURL + + renderLockedChart := func(chartDir string, chartName string, scenarioName string, serverURL string, images []*tu.ImageData) string { + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, + )) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, + ) + require.NoError(err) + require.NoError(os.WriteFile(filepath.Join(chartDir, "Images.lock"), []byte(data), 0644)) + return chartDir + } + + t.Run("Handle errors", func(t *testing.T) { + t.Run("Non-existent Helm chart", func(t *testing.T) { + dt("images", "verify", sb.TempFile()).AssertErrorMatch(t, "Helm chart.*does not exist") + }) + t.Run("Missing Images.lock", func(t *testing.T) { + chartName := "test" + scenarioName := "plain-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, "Images": nil, + "Name": chartName, "RepositoryURL": serverURL, + }, + )) + dt("images", "verify", chartDir).AssertErrorMatch(t, "failed to open Images.lock file") + }) + t.Run("Handle malformed Images.lock", func(t *testing.T) { + chartName := "test" + scenarioName := "plain-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, "Images": nil, + "Name": chartName, "RepositoryURL": serverURL, + }, + )) + require.NoError(os.WriteFile(filepath.Join(chartDir, imagelock.DefaultImagesLockFileName), []byte("malformed lock"), 0644)) + dt("images", "verify", chartDir).AssertErrorMatch(t, "failed to load Images.lock") + }) + t.Run("Handle verify error", func(t *testing.T) { + images, err := s.LoadImagesFromFile("../../testdata/images.json") + require.NoError(err) + scenarioName := "custom-chart" + chartName := "test" + + chartDir := renderLockedChart(sb.TempFile(), chartName, scenarioName, serverURL, images) + // Modify images and override lock file + newDigest := digest.Digest("sha256:0000000000000000000000000000000000000000000000000000000000000000") + oldDigest := images[0].Digests[0].Digest + images[0].Digests[0].Digest = newDigest + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName}, + ) + require.NoError(err) + require.NoError(os.WriteFile(filepath.Join(chartDir, "Images.lock"), []byte(data), 0644)) + dt("images", "verify", "--insecure", chartDir).AssertErrorMatch(t, fmt.Sprintf(`.*Images.lock does not validate: +.*Helm chart "test": image ".*%s": digests do not match:\s*.*- %s\s*\s*\+ %s.*`, images[0].Image, newDigest, oldDigest)) + }) + }) + t.Run("Verify Helm chart", func(t *testing.T) { + images, err := s.LoadImagesFromFile("../../testdata/images.json") + require.NoError(err) + + scenarioName := "custom-chart" + chartName := "test" + originChart := renderLockedChart(sb.TempFile(), chartName, scenarioName, serverURL, images) + + dt("images", "verify", "--insecure", originChart).AssertSuccessMatch(t, "") + }) + +} diff --git a/cmd/dt/version.go b/cmd/dt/version.go new file mode 100644 index 0000000..a2d5248 --- /dev/null +++ b/cmd/dt/version.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// Version is the tool version +var Version = "0.4.1" + +// BuildDate is the tool build date +var BuildDate = "" + +// Commit is the commit sha of the code used to build the tool +var Commit = "" + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Prints the version", + Run: func(_ *cobra.Command, _ []string) { + msg := fmt.Sprintf("Distribution Tooling for Helm %s\n", Version) + if BuildDate != "" { + msg += fmt.Sprintf("Built on: %s\n", BuildDate) + } + if Commit != "" { + msg += fmt.Sprintf("Git Commit: %s\n", Commit) + } + fmt.Print(msg) + os.Exit(0) + }, +} + +func init() { + Version = strings.TrimSpace(Version) + BuildDate = strings.TrimSpace(BuildDate) + Commit = strings.TrimSpace(Commit) +} diff --git a/cmd/dt/version_test.go b/cmd/dt/version_test.go new file mode 100644 index 0000000..0284a69 --- /dev/null +++ b/cmd/dt/version_test.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func (suite *CmdSuite) TestVersionCommand() { + dt("version").AssertSuccessMatch(suite.T(), fmt.Sprintf("^Distribution Tooling for Helm %s", Version)) +} diff --git a/cmd/dt/wrap/wrap.go b/cmd/dt/wrap/wrap.go new file mode 100644 index 0000000..34b8590 --- /dev/null +++ b/cmd/dt/wrap/wrap.go @@ -0,0 +1,502 @@ +// Package wrap implements the command to wrap a Helm chart +package wrap + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/carvelize" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/config" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/lock" + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/pull" + "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/logrus" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/wrapping" +) + +// Auth defines the authentication information to access the container registry +type Auth struct { + Username string + Password string +} + +// Config defines the configuration for the Wrap/Unwrap command +type Config struct { + Context context.Context + AnnotationsKey string + UsePlainHTTP bool + Insecure bool + Platforms []string + logger log.SectionLogger + TempDirectory string + Version string + Carvelize bool + KeepArtifacts bool + FetchArtifacts bool + Auth Auth + ContainerRegistryAuth Auth + OutputFile string +} + +// WithKeepArtifacts configures the KeepArtifacts of the WrapConfig +func WithKeepArtifacts(keepArtifacts bool) func(c *Config) { + return func(c *Config) { + c.KeepArtifacts = keepArtifacts + } +} + +// WithOutputFile configures the OutputFile of the WrapConfig +func WithOutputFile(outputFile string) func(c *Config) { + return func(c *Config) { + c.OutputFile = outputFile + } +} + +// WithAuth configures the Auth of the wrap Config +func WithAuth(username, password string) func(c *Config) { + return func(c *Config) { + c.Auth = Auth{ + Username: username, + Password: password, + } + } +} + +// WithContainerRegistryAuth configures the Auth of the wrap Config +func WithContainerRegistryAuth(username, password string) func(c *Config) { + return func(c *Config) { + c.ContainerRegistryAuth = Auth{ + Username: username, + Password: password, + } + } +} + +// ShouldFetchChartArtifacts returns true if the chart artifacts should be fetched +func (c *Config) ShouldFetchChartArtifacts(inputChart string) bool { + if chartutils.IsRemoteChart(inputChart) { + return c.FetchArtifacts + } + return false +} + +// Option defines a WrapOpts setting +type Option func(*Config) + +// WithInsecure configures the InsecureMode of the WrapConfig +func WithInsecure(insecure bool) func(c *Config) { + return func(c *Config) { + c.Insecure = insecure + } +} + +// WithUsePlainHTTP configures the UsePlainHTTP of the WrapConfig +func WithUsePlainHTTP(usePlainHTTP bool) func(c *Config) { + return func(c *Config) { + c.UsePlainHTTP = usePlainHTTP + } +} + +// WithAnnotationsKey configures the AnnotationsKey of the WrapConfig +func WithAnnotationsKey(annotationsKey string) func(c *Config) { + return func(c *Config) { + c.AnnotationsKey = annotationsKey + } +} + +// WithCarvelize configures the Carvelize of the WrapConfig +func WithCarvelize(carvelize bool) func(c *Config) { + return func(c *Config) { + c.Carvelize = carvelize + } +} + +// WithFetchArtifacts configures the FetchArtifacts of the WrapConfig +func WithFetchArtifacts(fetchArtifacts bool) func(c *Config) { + return func(c *Config) { + c.FetchArtifacts = fetchArtifacts + } +} + +// WithVersion configures the Version of the WrapConfig +func WithVersion(version string) func(c *Config) { + return func(c *Config) { + c.Version = version + } +} + +// WithLogger configures the Logger of the WrapConfig +func WithLogger(logger log.SectionLogger) func(c *Config) { + return func(c *Config) { + c.logger = logger + } +} + +// WithContext configures the Context of the WrapConfig +func WithContext(ctx context.Context) func(c *Config) { + return func(c *Config) { + c.Context = ctx + } +} + +// GetTemporaryDirectory returns the temporary directory of the WrapConfig +func (c *Config) GetTemporaryDirectory() (string, error) { + if c.TempDirectory != "" { + return c.TempDirectory, nil + } + + dir, err := os.MkdirTemp("", "chart-*") + if err != nil { + return "", err + } + c.TempDirectory = dir + return dir, nil +} + +// GetLogger returns the logger of the WrapConfig +func (c *Config) GetLogger() log.SectionLogger { + if c.logger != nil { + return c.logger + } + return logrus.NewSectionLogger() +} + +// WithPlatforms configures the Platforms of the WrapConfig +func WithPlatforms(platforms []string) func(c *Config) { + return func(c *Config) { + c.Platforms = platforms + } +} + +// WithTempDirectory configures the TempDirectory of the WrapConfig +func WithTempDirectory(tempDir string) func(c *Config) { + return func(c *Config) { + c.TempDirectory = tempDir + } +} + +// NewConfig returns a new WrapConfig with default values +func NewConfig(opts ...Option) *Config { + cfg := &Config{ + Context: context.Background(), + TempDirectory: "", + logger: logrus.NewSectionLogger(), + AnnotationsKey: imagelock.DefaultAnnotationsKey, + Platforms: []string{}, + } + + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// Chart wraps a Helm chart +func Chart(inputPath string, opts ...Option) (string, error) { + return wrapChart(inputPath, opts...) +} + +// ResolveInputChartPath resolves the input chart into a local uncompressed chart path +func ResolveInputChartPath(inputPath string, cfg *Config) (string, error) { + l := cfg.GetLogger() + var chartPath string + var err error + + tmpDir, err := cfg.GetTemporaryDirectory() + + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + + if chartutils.IsRemoteChart(inputPath) { + if err := l.ExecuteStep("Fetching remote Helm chart", func() error { + version := cfg.Version + + chartPath, err = fetchRemoteChart(inputPath, version, tmpDir, cfg) + if err != nil { + return err + } + return nil + }); err != nil { + return "", l.Failf("Failed to download Helm chart: %w", err) + } + l.Infof("Helm chart downloaded to %q", chartPath) + } else if isTar, _ := utils.IsTarFile(inputPath); isTar { + if err := l.ExecuteStep("Uncompressing Helm chart", func() error { + var err error + chartPath, err = untarChart(inputPath, tmpDir) + return err + }); err != nil { + return "", l.Failf("Failed to uncompress %q: %w", inputPath, err) + } + l.Infof("Helm chart uncompressed to %q", chartPath) + } else { + chartPath = inputPath + } + + return chartPath, nil +} + +func untarChart(chartFile string, dir string) (string, error) { + sandboxDir, err := os.MkdirTemp(dir, "dt-wrap*") + if err != nil { + return "", fmt.Errorf("failed to create sandbox directory") + } + if err := utils.Untar(chartFile, sandboxDir, utils.TarConfig{StripComponents: 1}); err != nil { + return "", err + } + return sandboxDir, nil +} + +func fetchRemoteChart(chartURL string, version string, dir string, cfg *Config) (string, error) { + d, err := cfg.GetTemporaryDirectory() + if err != nil { + return "", err + } + chartPath, err := artifacts.PullChart( + chartURL, version, dir, + artifacts.WithInsecure(cfg.Insecure), + artifacts.WithPlainHTTP(cfg.UsePlainHTTP), + artifacts.WithRegistryAuth(cfg.Auth.Username, cfg.Auth.Password), + artifacts.WithTempDir(d), + ) + if err != nil { + return "", err + } + return chartPath, nil +} + +func validateWrapLock(wrap wrapping.Wrap, cfg *Config) error { + l := cfg.GetLogger() + chart := wrap.Chart() + + lockFile := wrap.LockFilePath() + if utils.FileExists(lockFile) { + if err := l.ExecuteStep("Verifying Images.lock", func() error { + return wrap.VerifyLock(imagelock.WithAnnotationsKey(cfg.AnnotationsKey), + imagelock.WithContext(cfg.Context), + imagelock.WithAuth(cfg.ContainerRegistryAuth.Username, cfg.ContainerRegistryAuth.Password), + imagelock.WithInsecure(cfg.Insecure)) + }); err != nil { + return l.Failf("Failed to verify lock: %w", err) + } + l.Infof("Helm chart %q lock is valid", chart.RootDir()) + } else { + if err := l.ExecuteStep( + "Images.lock file does not exist. Generating it from annotations...", + func() error { + return lock.Create(chart.RootDir(), lockFile, silent.NewLogger(), + imagelock.WithAnnotationsKey(cfg.AnnotationsKey), + imagelock.WithInsecure(cfg.Insecure), + imagelock.WithAuth(cfg.ContainerRegistryAuth.Username, cfg.ContainerRegistryAuth.Password), + imagelock.WithPlatforms(cfg.Platforms), + imagelock.WithContext(cfg.Context), + ) + }, + ); err != nil { + return l.Failf("Failed to generate lock: %w", err) + } + l.Infof("Images.lock file written to %q", lockFile) + } + return nil +} + +func fetchArtifacts(chartURL string, destDir string, cfg *Config) error { + if err := artifacts.FetchChartMetadata( + context.Background(), chartURL, + destDir, artifacts.WithAuth(cfg.Auth.Username, cfg.Auth.Password), + ); err != nil && err != artifacts.ErrTagDoesNotExist { + return fmt.Errorf("failed to fetch chart remote metadata: %w", err) + } + return nil +} + +func pullImages(wrap wrapping.Wrap, cfg *Config) error { + l := cfg.GetLogger() + + lock, err := wrap.GetImagesLock() + if err != nil { + return l.Failf("Failed to load Images.lock: %v", err) + } + if len(lock.Images) == 0 { + l.Warnf("No images found in Images.lock") + } else { + return l.Section(fmt.Sprintf("Pulling images into %q", wrap.ImagesDir()), func(childLog log.SectionLogger) error { + if err := pull.ChartImages( + wrap, + wrap.ImagesDir(), + chartutils.WithLog(childLog), + chartutils.WithContext(cfg.Context), + chartutils.WithFetchArtifacts(cfg.FetchArtifacts), + chartutils.WithAuth(cfg.ContainerRegistryAuth.Username, cfg.ContainerRegistryAuth.Password), + chartutils.WithArtifactsDir(wrap.ImageArtifactsDir()), + chartutils.WithProgressBar(childLog.ProgressBar()), + ); err != nil { + return childLog.Failf("%v", err) + } + childLog.Infof("All images pulled successfully") + return nil + }) + } + return nil +} + +func wrapChart(inputPath string, opts ...Option) (string, error) { + cfg := NewConfig(opts...) + + ctx := cfg.Context + parentLog := cfg.GetLogger() + + l := parentLog.StartSection(fmt.Sprintf("Wrapping Helm chart %q", inputPath)) + + subCfg := NewConfig(append(opts, WithLogger(l))...) + + chartPath, err := ResolveInputChartPath(inputPath, subCfg) + if err != nil { + return "", err + } + tmpDir, err := cfg.GetTemporaryDirectory() + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + wrap, err := wrapping.Create(chartPath, filepath.Join(tmpDir, "wrap"), + chartutils.WithAnnotationsKey(cfg.AnnotationsKey), + ) + if err != nil { + return "", l.Failf("failed to create wrap: %v", err) + } + + chart := wrap.Chart() + + if cfg.ShouldFetchChartArtifacts(inputPath) { + chartURL := fmt.Sprintf("%s:%s", inputPath, chart.Version()) + if err := fetchArtifacts(chartURL, filepath.Join(wrap.RootDir(), artifacts.HelmChartArtifactMetadataDir), subCfg); err != nil { + return "", err + } + } + + chartRoot := chart.RootDir() + if err := validateWrapLock(wrap, subCfg); err != nil { + return "", err + } + + outputFile := cfg.OutputFile + + if outputFile == "" { + outputBaseName := fmt.Sprintf("%s-%s.wrap.tgz", chart.Name(), chart.Version()) + if outputFile, err = filepath.Abs(outputBaseName); err != nil { + l.Debugf("failed to normalize output file: %v", err) + outputFile = filepath.Join(filepath.Dir(chartRoot), outputBaseName) + } + } + if err := pullImages(wrap, subCfg); err != nil { + return "", err + } + + if cfg.Carvelize { + if err := l.Section(fmt.Sprintf("Generating Carvel bundle for Helm chart %q", chartPath), func(childLog log.SectionLogger) error { + return carvelize.GenerateBundle( + chartRoot, + chartutils.WithAnnotationsKey(cfg.AnnotationsKey), + chartutils.WithLog(childLog), + ) + }); err != nil { + return "", l.Failf("%w", err) + } + l.Infof("Carvel bundle created successfully") + } + + if err := l.ExecuteStep( + "Compressing Helm chart...", + func() error { + return utils.TarContext(ctx, wrap.RootDir(), outputFile, utils.TarConfig{ + Prefix: fmt.Sprintf("%s-%s", chart.Name(), chart.Version()), + }) + }, + ); err != nil { + return "", l.Failf("failed to wrap Helm chart: %w", err) + } + l.Infof("Compressed into %q", outputFile) + + return outputFile, nil +} + +// NewCmd builds a new wrap command +func NewCmd(cfg *config.Config) *cobra.Command { + var outputFile string + var version string + var platforms []string + var fetchArtifacts bool + var carvelize bool + var examples = ` # Wrap a Helm chart from a local folder + $ dt wrap examples/mariadb + + # Wrap a Helm chart in an OCI registry + $ dt wrap oci://docker.io/bitnamicharts/mariadb + ` + cmd := &cobra.Command{ + Use: "wrap CHART_PATH|OCI_URI", + Short: "Wraps a Helm chart", + Long: `Wraps a Helm chart either local or remote into a distributable package. +This command will pull all the container images and wrap it into a single tarball along with the Images.lock and metadata`, + Example: examples, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + chartPath := args[0] + + ctx, cancel := cfg.ContextWithSigterm() + defer cancel() + + tmpDir, err := config.GetGlobalTempWorkDir() + if err != nil { + return err + } + + parentLog := cfg.Logger() + + wrappedChart, err := wrapChart(chartPath, + WithLogger(parentLog), + WithAnnotationsKey(cfg.AnnotationsKey), WithContext(ctx), + WithPlatforms(platforms), WithVersion(version), + WithFetchArtifacts(fetchArtifacts), WithCarvelize(carvelize), + WithUsePlainHTTP(cfg.UsePlainHTTP), WithInsecure(cfg.Insecure), + WithOutputFile(outputFile), + WithTempDirectory(tmpDir), + ) + + if err != nil { + if _, ok := err.(*log.LoggedError); ok { + // We already logged it, lets be less verbose + return fmt.Errorf("failed to wrap Helm chart: %v", err) + } + return err + } + + parentLog.Printf(widgets.TerminalSpacer) + parentLog.Successf("Helm chart wrapped into %q", wrappedChart) + + return nil + }, + } + + cmd.PersistentFlags().StringVar(&version, "version", version, "when wrapping remote Helm charts from OCI, version to request") + cmd.PersistentFlags().StringVar(&outputFile, "output-file", outputFile, "generate a tar.gz with the output of the pull operation") + cmd.PersistentFlags().StringSliceVar(&platforms, "platforms", platforms, "platforms to include in the Images.lock file") + cmd.PersistentFlags().BoolVar(&carvelize, "add-carvel-bundle", carvelize, "whether the wrap should include a Carvel bundle or not") + cmd.PersistentFlags().BoolVar(&fetchArtifacts, "fetch-artifacts", fetchArtifacts, "fetch remote metadata and signature artifacts") + + return cmd +} diff --git a/cmd/dt/wrap_test.go b/cmd/dt/wrap_test.go new file mode 100644 index 0000000..25aa05e --- /dev/null +++ b/cmd/dt/wrap_test.go @@ -0,0 +1,429 @@ +package main + +import ( + "context" + "fmt" + + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v3/pkg/repo/repotest" + + "github.com/vmware-labs/distribution-tooling-for-helm/cmd/dt/wrap" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/carvel" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/logrus" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/wrapping" + "gopkg.in/yaml.v3" +) + +const ( + WithArtifacts = true + WithoutArtifacts = false +) + +type wrapOpts struct { + FetchArtifacts bool + GenerateCarvelBundle bool + ChartName string + Version string + OutputFile string + SkipExpectedLock bool + Images []tu.ImageData + ArtifactsMetadata map[string][]byte + UseAPI bool + Auth tu.Auth + ContainerRegistryAuth tu.Auth +} + +func verifyArtifactsContents(t *testing.T, sb *tu.Sandbox, dir string, artifactsData map[string][]byte) { + plainMetadataDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(t, err) + require.NoError(t, tu.UnpackOCILayout(context.Background(), dir, plainMetadataDir)) + + for fileName, data := range artifactsData { + got, err := os.ReadFile(filepath.Join(plainMetadataDir, fileName)) + require.NoError(t, err) + require.Equal(t, data, got) + } +} + +func verifyChartWrappedArtifacts(t *testing.T, sb *tu.Sandbox, wrapDir string, images []tu.ImageData, artifactsData map[string][]byte) { + wrap, err := wrapping.Load(wrapDir) + require.NoError(t, err) + artifactsDir := filepath.Join(wrapDir, artifacts.HelmArtifactsFolder) + require.DirExists(t, artifactsDir) + require.DirExists(t, filepath.Join(artifactsDir, "images")) + for _, imgData := range images { + imageTag := "latest" + idx := strings.LastIndex(imgData.Image, ":") + if idx != -1 { + imageTag = imgData.Image[idx+1:] + } + imageArtifactDir := filepath.Join(artifactsDir, fmt.Sprintf("images/%s/%s", wrap.Chart().Name(), imgData.Name)) + require.DirExists(t, imageArtifactDir) + for _, dir := range []string{"sig", "metadata", "metadata.sig"} { + imageArtifactDir := filepath.Join(imageArtifactDir, fmt.Sprintf("%s.%s", imageTag, dir)) + require.DirExists(t, imageArtifactDir) + // Basic validation of the oci-layout dir + for _, f := range []string{"index.json", "oci-layout"} { + require.FileExists(t, filepath.Join(imageArtifactDir, f)) + } + require.DirExists(t, filepath.Join(imageArtifactDir, "blobs")) + + // For the "metadata" dir, check the bundle assets match what we provided + if dir == "metadata" { + verifyArtifactsContents(t, sb, imageArtifactDir, artifactsData) + } + } + } +} + +func testChartWrap(t *testing.T, sb *tu.Sandbox, inputChart string, expectedLock map[string]interface{}, + cfg wrapOpts) string { + t.Helper() + + // Setup a working directory to look for the wrap when not providing a output-filename + currentDir, err := os.Getwd() + require.NoError(t, err) + + workingDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(t, err) + defer os.Chdir(currentDir) + + require.NoError(t, os.Chdir(workingDir)) + + var expectedWrapFile string + args := []string{"wrap", inputChart, "--use-plain-http"} + if cfg.OutputFile != "" { + expectedWrapFile = cfg.OutputFile + args = append(args, "--output-file", expectedWrapFile) + } else { + expectedWrapFile = filepath.Join(workingDir, fmt.Sprintf("%s-%v.wrap.tgz", cfg.ChartName, cfg.Version)) + } + if cfg.GenerateCarvelBundle { + args = append(args, "--add-carvel-bundle") + } + if cfg.FetchArtifacts { + args = append(args, "--fetch-artifacts") + } + + if cfg.UseAPI { + l := logrus.NewSectionLogger() + l.SetWriter(io.Discard) + opts := []wrap.Option{ + wrap.WithLogger(l), + wrap.WithUsePlainHTTP(true), + wrap.WithCarvelize(cfg.GenerateCarvelBundle), + wrap.WithFetchArtifacts(cfg.FetchArtifacts), + wrap.WithAuth(cfg.Auth.Username, cfg.Auth.Password), + wrap.WithOutputFile(expectedWrapFile), + wrap.WithContainerRegistryAuth(cfg.ContainerRegistryAuth.Username, cfg.ContainerRegistryAuth.Password), + } + _, err := wrap.Chart(inputChart, opts...) + require.NoError(t, err) + } else { + if len(cfg.Images) == 0 { + dt(args...).AssertSuccessMatch(t, "No images found in Images.lock") + } else { + dt(args...).AssertSuccess(t) + } + } + require.FileExists(t, expectedWrapFile) + + tmpDir := sb.TempFile() + require.NoError(t, utils.Untar(expectedWrapFile, tmpDir, utils.TarConfig{StripComponents: 1})) + + imagesDir := filepath.Join(tmpDir, "images") + if len(cfg.Images) == 0 { + require.NoDirExists(t, imagesDir) + } else { + require.DirExists(t, imagesDir) + for _, imgData := range cfg.Images { + for _, digestData := range imgData.Digests { + imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", digestData.Digest.Encoded())) + assert.DirExists(t, imgDir) + } + } + } + wrappedChartDir := filepath.Join(tmpDir, "chart") + lockFile := filepath.Join(wrappedChartDir, "Images.lock") + assert.FileExists(t, lockFile) + + if cfg.GenerateCarvelBundle { + carvelBundleFile := filepath.Join(wrappedChartDir, carvel.CarvelBundleFilePath) + assert.FileExists(t, carvelBundleFile) + carvelImagesLockFile := filepath.Join(wrappedChartDir, carvel.CarvelImagesFilePath) + assert.FileExists(t, carvelImagesLockFile) + } + + newData, err := os.ReadFile(lockFile) + require.NoError(t, err) + var newLock map[string]interface{} + require.NoError(t, yaml.Unmarshal(newData, &newLock)) + // Clear the timestamp + newLock["metadata"] = nil + if !cfg.SkipExpectedLock { + assert.Equal(t, expectedLock, newLock) + } + + if cfg.FetchArtifacts { + if len(cfg.ArtifactsMetadata) > 0 { + verifyChartWrappedArtifacts(t, sb, tmpDir, cfg.Images, cfg.ArtifactsMetadata) + } + } else { + // We did not requested fetching artifacts. Make sure they are not grabbed + assert.NoDirExists(t, filepath.Join(tmpDir, artifacts.HelmArtifactsFolder)) + } + return tmpDir +} + +func (suite *CmdSuite) TestWrapCommand() { + t := suite.T() + require := suite.Require() + + const ( + withLock = true + withoutLock = false + ) + + tests := []struct { + name string + auth bool + }{ + {name: "WithoutAuth", auth: false}, + {name: "WithAuth", auth: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var username, password string + var registryURL string + var useAPI bool + if tc.auth { + useAPI = true + + srv, err := repotest.NewTempServerWithCleanup(t, "") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + ociSrv, err := tu.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + go ociSrv.ListenAndServe() + + username = "username" + password = "password" + + registryURL = ociSrv.RegistryURL + } else { + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + registryURL = u.Host + } + + imageTag := "mytag" + imageName := fmt.Sprintf("test:%s", imageTag) + + sb := suite.sb + + certDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(err) + + keyFile, _, err := tu.GenerateCosignCertificateFiles(certDir) + require.NoError(err) + + metadataDir, err := sb.Mkdir(sb.TempFile(), 0755) + require.NoError(err) + + metdataFileText := "this is a sample text" + + metadataArtifacts := map[string][]byte{ + "metadata.txt": []byte(metdataFileText), + } + for fileName, data := range metadataArtifacts { + _, err := sb.Write(filepath.Join(metadataDir, fileName), string(data)) + require.NoError(err) + } + + images, err := tu.AddSampleImagesToRegistry(imageName, registryURL, tu.WithSignKey(keyFile), tu.WithMetadataDir(metadataDir), tu.WithAuth(username, password)) + if err != nil { + t.Fatal(err) + } + + serverURL := registryURL + scenarioName := "complete-chart" + chartName := "test" + version := "1.0.0" + scenarioDir, err := filepath.Abs(fmt.Sprintf("../../testdata/scenarios/%s", scenarioName)) + require.NoError(err) + + createSampleChart := func(chartDir string, withLock bool) string { + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version, "RepositoryURL": serverURL}, + )) + if !withLock { + // We do not want the lock file to be present, wrap should take care of it + require.NoError(os.RemoveAll(filepath.Join(chartDir, "Images.lock"))) + } + return chartDir + } + testWrap := func(t *testing.T, inputChart string, outputFile string, expectedLock map[string]interface{}, + generateCarvelBundle bool, fetchArtifacts bool, useAPI bool, contUser, contPass string, username, password string) string { + return testChartWrap(t, sb, inputChart, expectedLock, wrapOpts{ + FetchArtifacts: fetchArtifacts, + GenerateCarvelBundle: generateCarvelBundle, + ChartName: chartName, + Version: version, + OutputFile: outputFile, + ArtifactsMetadata: metadataArtifacts, + Images: images, + UseAPI: useAPI, + ContainerRegistryAuth: tu.Auth{Username: contUser, Password: contPass}, + Auth: tu.Auth{Username: username, Password: password}, + }) + } + testSampleWrap := func(t *testing.T, withLock bool, outputFile string, generateCarvelBundle bool, + fetchArtifacts bool, useAPI bool, username string, password string) { + chartDir := createSampleChart(sb.TempFile(), withLock) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version}, + ) + require.NoError(err) + var expectedLock map[string]interface{} + require.NoError(yaml.Unmarshal([]byte(data), &expectedLock)) + + // Clear the timestamp + expectedLock["metadata"] = nil + + testWrap(t, chartDir, outputFile, expectedLock, generateCarvelBundle, fetchArtifacts, useAPI, username, password, "", "") + } + + t.Run("Wrap Chart without existing lock", func(t *testing.T) { + testSampleWrap(t, withoutLock, "", false, WithoutArtifacts, useAPI, username, password) + }) + t.Run("Wrap Chart with existing lock", func(t *testing.T) { + testSampleWrap(t, withLock, "", false, WithoutArtifacts, useAPI, username, password) + }) + t.Run("Wrap Chart From compressed tgz", func(t *testing.T) { + chartDir := createSampleChart(sb.TempFile(), withLock) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version}, + ) + require.NoError(err) + var expectedLock map[string]interface{} + require.NoError(yaml.Unmarshal([]byte(data), &expectedLock)) + + // Clear the timestamp + expectedLock["metadata"] = nil + + tarFilename := fmt.Sprintf("%s/chart.tar.gz", sb.TempFile()) + + require.NoError(utils.Tar(chartDir, tarFilename, utils.TarConfig{})) + require.FileExists(tarFilename) + + testWrap(t, tarFilename, "", expectedLock, false, WithoutArtifacts, useAPI, username, password, "", "") + }) + + t.Run("Wrap Chart From oci", func(t *testing.T) { + var ociServerURL string + var ociUser, ociPass string + if tc.auth { + ociUser = "username2" + ociPass = "password2" + srv, err := repotest.NewTempServerWithCleanup(t, "") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + ociSrv, err := tu.NewOCIServerWithCustomCreds(t, srv.Root(), ociUser, ociPass) + if err != nil { + t.Fatal(err) + } + go ociSrv.ListenAndServe() + + ociServerURL = ociSrv.RegistryURL + } else { + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + ociServerURL = u.Host + } + + chartDir := createSampleChart(sb.TempFile(), withLock) + + data, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "imagelock.partial.tmpl"), + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": version}, + ) + require.NoError(err) + var expectedLock map[string]interface{} + require.NoError(yaml.Unmarshal([]byte(data), &expectedLock)) + + // Clear the timestamp + expectedLock["metadata"] = nil + + tarFilename := fmt.Sprintf("%s/chart.tar.gz", sb.TempFile()) + + require.NoError(utils.Tar(chartDir, tarFilename, utils.TarConfig{})) + require.FileExists(tarFilename) + pushChartURL := fmt.Sprintf("oci://%s/charts", ociServerURL) + fullChartURL := fmt.Sprintf("%s/%s", pushChartURL, chartName) + + require.NoError(artifacts.PushChart(tarFilename, pushChartURL, artifacts.WithRegistryAuth(ociUser, ociPass), artifacts.WithPlainHTTP(true))) + t.Run("With artifacts", func(t *testing.T) { + testWrap(t, fullChartURL, "", expectedLock, false, WithArtifacts, useAPI, username, password, ociUser, ociPass) + }) + t.Run("Without artifacts", func(t *testing.T) { + testWrap(t, fullChartURL, "", expectedLock, false, WithoutArtifacts, useAPI, username, password, ociUser, ociPass) + }) + }) + + t.Run("Wrap Chart with custom output filename", func(t *testing.T) { + tempFilename := fmt.Sprintf("%s/chart.wrap.tar.gz", sb.TempFile()) + testSampleWrap(t, withLock, tempFilename, false, WithoutArtifacts, useAPI, username, password) + // This should already be handled by testWrap, but make sure it is there + suite.Assert().FileExists(tempFilename) + }) + + t.Run("Wrap Chart and generate carvel bundle", func(t *testing.T) { + tempFilename := fmt.Sprintf("%s/chart.wrap.tar.gz", sb.TempFile()) + testSampleWrap(t, withLock, tempFilename, true, WithoutArtifacts, useAPI, username, password) // triggers the Carvel checks + }) + + t.Run("Wrap Chart with no images", func(t *testing.T) { + images = []tu.ImageData{} + scenarioName = "no-images-chart" + scenarioDir = fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + testSampleWrap(t, withLock, "", false, WithoutArtifacts, useAPI, username, password) + }) + }) + } +} diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..995bff9 Binary files /dev/null and b/demo.gif differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42bd1b9 --- /dev/null +++ b/go.mod @@ -0,0 +1,327 @@ +module github.com/vmware-labs/distribution-tooling-for-helm + +go 1.21 + +require ( + github.com/DataDog/go-tuf v1.0.2-0.5.2 + github.com/Masterminds/sprig/v3 v3.2.3 + github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 + github.com/google/go-containerregistry v0.19.1 + github.com/opencontainers/go-digest v1.0.0 + github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 + github.com/pterm/pterm v0.12.78 + github.com/sigstore/cosign/v2 v2.2.4 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.9.0 + github.com/vmware-labs/yaml-jsonpath v0.3.2 + gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 + helm.sh/helm/v3 v3.14.2 + oras.land/oras-go/v2 v2.4.0 +) + +require ( + cloud.google.com/go/compute v1.25.0 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e // indirect + cuelang.org/go v0.8.1 // indirect + filippo.io/edwards25519 v1.1.0 // indirect + github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/alibabacloudsdkgo/helper v0.2.0 // indirect + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.29 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect + github.com/Azure/go-autorest/logger v0.2.1 // indirect + github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/hcsshim v0.11.4 // indirect + github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect + github.com/ThalesIgnite/crypto11 v1.2.5 // indirect + github.com/agnivade/levenshtein v1.1.1 // indirect + github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 // indirect + github.com/alibabacloud-go/cr-20160607 v1.0.1 // indirect + github.com/alibabacloud-go/cr-20181201 v1.0.10 // indirect + github.com/alibabacloud-go/darabonba-openapi v0.2.1 // indirect + github.com/alibabacloud-go/debug v1.0.0 // indirect + github.com/alibabacloud-go/endpoint-util v1.1.1 // indirect + github.com/alibabacloud-go/openapi-util v0.1.0 // indirect + github.com/alibabacloud-go/tea v1.2.2 // indirect + github.com/alibabacloud-go/tea-utils v1.4.5 // indirect + github.com/alibabacloud-go/tea-xml v1.1.3 // indirect + github.com/aliyun/credentials-go v1.3.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.26.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.27.9 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.24.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.21.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 // indirect + github.com/aws/smithy-go v1.20.1 // indirect + github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20240116161626-88cfadc80e8f // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect + github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd // indirect + github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b // indirect + github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 // indirect + github.com/buildkite/agent/v3 v3.62.0 // indirect + github.com/buildkite/go-pipeline v0.3.2 // indirect + github.com/buildkite/interpolate v0.0.0-20200526001904-07f35b4ae251 // indirect + github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/coreos/go-oidc/v3 v3.10.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect + github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emicklei/proto v1.13.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-chi/chi v4.1.2+incompatible // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.3 // indirect + github.com/go-jose/go-jose/v4 v4.0.1 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/runtime v0.28.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-piv/piv-go v1.11.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/gomodule/redigo v1.8.9 // indirect + github.com/google/certificate-transparency-go v1.1.8 // indirect + github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect + github.com/google/go-github/v55 v55.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/gorilla/handlers v1.5.1 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/in-toto/in-toto-golang v0.9.0 // indirect + github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/letsencrypt/boulder v0.0.0-20240205192639-0e9f5d35457f // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mozillazg/docker-credential-acr-helper v0.3.0 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/oleiade/reflections v1.0.1 // indirect + github.com/open-policy-agent/opa v0.63.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 // indirect + github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/fulcio v1.4.5 // indirect + github.com/sigstore/rekor v1.3.6 // indirect + github.com/sigstore/sigstore v1.8.3 // indirect + github.com/sigstore/timestamp-authority v1.2.2 // indirect + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/viper v1.18.2 // indirect + github.com/spiffe/go-spiffe/v2 v2.2.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect + github.com/tchap/go-patricia/v2 v2.3.1 // indirect + github.com/thales-e-security/pool v0.0.2 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/xanzy/go-gitlab v0.102.0 // indirect + github.com/yashtewari/glob-intersection v0.2.0 // indirect + github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 // indirect + github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 // indirect + github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f // indirect + github.com/zeebo/errs v1.3.0 // indirect + go.mongodb.org/mongo-driver v1.14.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/sdk v1.24.0 // indirect + go.step.sm/crypto v0.44.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/mod v0.16.0 // indirect + golang.org/x/tools v0.19.0 // indirect + google.golang.org/api v0.172.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect + gopkg.in/evanphx/json-patch.v5 v5.9.0 // indirect + gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + oras.land/oras-go v1.2.5 // indirect + sigs.k8s.io/release-utils v0.7.7 // indirect +) + +require ( + atomicgo.dev/cursor v0.2.0 // indirect + atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/schedule v0.1.0 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/containerd/console v1.0.4 // indirect + github.com/containerd/containerd v1.7.14 + github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/cli v25.0.2+incompatible + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v25.0.5+incompatible // indirect + github.com/docker/docker-credential-helpers v0.8.1 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-metrics v0.0.1 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/emicklei/go-restful/v3 v3.11.2 // indirect + github.com/evanphx/json-patch v5.9.0+incompatible // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gookit/color v1.5.4 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/huandu/xstrings v1.4.0 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/spdystream v0.2.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/opencontainers/image-spec v1.1.0 + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.19.0 // indirect + github.com/prometheus/client_model v0.6.0 // indirect + github.com/prometheus/common v0.51.1 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/rivo/uniseg v0.4.6 // indirect + github.com/rubenv/sql-migrate v1.6.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/vbatts/tar-split v0.11.5 // indirect + github.com/vmware-tanzu/carvel-imgpkg v0.38.3 + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.starlark.net v0.0.0-20240123142251-f86470692795 // indirect + golang.org/x/crypto v0.22.0 + golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.19.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/term v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.29.1 // indirect + k8s.io/apiextensions-apiserver v0.29.1 // indirect + k8s.io/apimachinery v0.29.1 // indirect + k8s.io/apiserver v0.29.1 // indirect + k8s.io/cli-runtime v0.29.1 // indirect + k8s.io/client-go v0.29.1 // indirect + k8s.io/component-base v0.29.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240126223410-2919ad4fcfec // indirect + k8s.io/kubectl v0.29.1 // indirect + k8s.io/utils v0.0.0-20240102154912-e7106e64919e // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/kustomize/api v0.16.0 // indirect + sigs.k8s.io/kustomize/kyaml v0.16.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0ae30d1 --- /dev/null +++ b/go.sum @@ -0,0 +1,1214 @@ +atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= +atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= +atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= +atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= +atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= +atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM= +cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU= +cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= +cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= +cloud.google.com/go/kms v1.15.8 h1:szIeDCowID8th2i8XE4uRev5PMxQFqW+JjwYxL9h6xs= +cloud.google.com/go/kms v1.15.8/go.mod h1:WoUHcDjD9pluCg7pNds131awnH429QGvRM3N/4MyoVs= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e h1:GwCVItFUPxwdsEYnlUcJ6PJxOjTeFFCKOh6QWg4oAzQ= +cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e/go.mod h1:ApHceQLLwcOkCEXM1+DyCXTHEJhNGDpJ2kmV6axsx24= +cuelang.org/go v0.8.1 h1:VFYsxIFSPY5KgSaH1jQ2GxHOrbu6Ga3kEI70yCZwnOg= +cuelang.org/go v0.8.1/go.mod h1:CoDbYolfMms4BhWUlhD+t5ORnihR7wvjcfgyO9lL5FI= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/alibabacloudsdkgo/helper v0.2.0 h1:8+4G8JaejP8Xa6W46PzJEwisNgBXMvFcz78N6zG/ARw= +github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/alibabacloudsdkgo/helper v0.2.0/go.mod h1:GgeIE+1be8Ivm7Sh4RgwI42aTtC9qrcj+Y9Y6CjJhJs= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.10.0 h1:n1DH8TPV4qqPTje2RcUBYwtrTWlabVp4n46+74X2pn4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.10.0/go.mod h1:HDcZnuGbiyppErN6lB+idp4CKhjbc8gwjto6OPpyggM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 h1:DRiANoJTiW6obBQe3SqZizkuV1PEgfiiGivmVocDy64= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0/go.mod h1:qLIye2hwb/ZouqhpSD9Zn3SJipvpEnz1Ywl3VUk9Y0s= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.24/go.mod h1:G6kyRlFnTuSbEYkQGawPfsCswgme4iYf6rfSKUDzbCc= +github.com/Azure/go-autorest/autorest v0.11.29 h1:I4+HL/JDvErx2LjyzaVxllw2lRDB5/BT2Bm4g20iqYw= +github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= +github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/adal v0.9.23 h1:Yepx8CvFxwNKpH6ja7RZ+sKX+DWYNldbLiALMC3BTz8= +github.com/Azure/go-autorest/autorest/adal v0.9.23/go.mod h1:5pcMqFkdPhviJdlEy3kC/v1ZLnQl0MH6XA5YCcMhy4c= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 h1:wkAZRgT/pn8HhFyzfe9UnqOjJYqlembgCTi72Bm/xKk= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.12/go.mod h1:84w/uV8E37feW2NCJ08uT9VBfjfUHpgLVnG2InYD6cg= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.5/go.mod h1:ADQAXrkgm7acgWVUNamOgh8YNrv4p27l3Wc55oVfpzg= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= +github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/DataDog/go-tuf v1.0.2-0.5.2 h1:EeZr937eKAWPxJ26IykAdWA4A0jQXJgkhUjqEI/w7+I= +github.com/DataDog/go-tuf v1.0.2-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= +github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= +github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= +github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= +github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= +github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/ThalesIgnite/crypto11 v1.2.5 h1:1IiIIEqYmBvUYFeMnHqRft4bwf/O36jryEUpY+9ef8E= +github.com/ThalesIgnite/crypto11 v1.2.5/go.mod h1:ILDKtnCKiQ7zRoNxcp36Y1ZR8LBPmR2E23+wTQe/MlE= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.2/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 h1:iC9YFYKDGEy3n/FtqJnOkZsene9olVspKmkX5A2YBEo= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/cr-20160607 v1.0.1 h1:WEnP1iPFKJU74ryUKh/YDPHoxMZawqlPajOymyNAkts= +github.com/alibabacloud-go/cr-20160607 v1.0.1/go.mod h1:QHeKZtZ3F3FOE+/uIXCBAp8POwnUYekpLwr1dtQa5r0= +github.com/alibabacloud-go/cr-20181201 v1.0.10 h1:B60f6S1imsgn2fgC6X6FrVNrONDrbCT0NwYhsJ0C9/c= +github.com/alibabacloud-go/cr-20181201 v1.0.10/go.mod h1:VN9orB/w5G20FjytoSpZROqu9ZqxwycASmGqYUJSoDc= +github.com/alibabacloud-go/darabonba-openapi v0.1.12/go.mod h1:sTAjsFJmVsmcVeklL9d9uDBlFsgl43wZ6jhI6BHqHqU= +github.com/alibabacloud-go/darabonba-openapi v0.1.14/go.mod h1:w4CosR7O/kapCtEEMBm3JsQqWBU/CnZ2o0pHorsTWDI= +github.com/alibabacloud-go/darabonba-openapi v0.2.1 h1:WyzxxKvhdVDlwpAMOHgAiCJ+NXa6g5ZWPFEzaK/ewwY= +github.com/alibabacloud-go/darabonba-openapi v0.2.1/go.mod h1:zXOqLbpIqq543oioL9IuuZYOQgHQ5B8/n5OPrnko8aY= +github.com/alibabacloud-go/darabonba-string v1.0.0/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA= +github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY= +github.com/alibabacloud-go/debug v1.0.0 h1:3eIEQWfay1fB24PQIEzXAswlVJtdQok8f3EVN5VrBnA= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/endpoint-util v1.1.1 h1:ZkBv2/jnghxtU0p+upSU0GGzW1VL9GQdZO3mcSUTUy8= +github.com/alibabacloud-go/endpoint-util v1.1.1/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/openapi-util v0.0.9/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.0.10/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg= +github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4= +github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.1.19/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A= +github.com/alibabacloud-go/tea v1.2.2 h1:aTsR6Rl3ANWPfqeQugPglfurloyBJY85eFy7Gc1+8oU= +github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk= +github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils v1.3.9/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE= +github.com/alibabacloud-go/tea-utils v1.4.3/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= +github.com/alibabacloud-go/tea-utils v1.4.5 h1:h0/6Xd2f3bPE4XHTvkpjwxowIwRCJAJOqY6Eq8f3zfA= +github.com/alibabacloud-go/tea-utils v1.4.5/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= +github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0= +github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= +github.com/aliyun/credentials-go v1.3.2 h1:L4WppI9rctC8PdlMgyTkF8bBsy9pyKQEzBD1bHMRl+g= +github.com/aliyun/credentials-go v1.3.2/go.mod h1:tlpz4uys4Rn7Ik4/piGRrTbXy2uLKvePgQJJduE+Y5c= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/aws/aws-sdk-go v1.51.6 h1:Ld36dn9r7P9IjU8WZSaswQ8Y/XUCRpewim5980DwYiU= +github.com/aws/aws-sdk-go v1.51.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= +github.com/aws/aws-sdk-go-v2 v1.26.0 h1:/Ce4OCiM3EkpW7Y+xUnfAFpchU78K7/Ug01sZni9PgA= +github.com/aws/aws-sdk-go-v2 v1.26.0/go.mod h1:35hUlJVYd+M++iLI3ALmVwMOyRYMmRqUXpTtRGW+K9I= +github.com/aws/aws-sdk-go-v2/config v1.27.9 h1:gRx/NwpNEFSk+yQlgmk1bmxxvQ5TyJ76CWXs9XScTqg= +github.com/aws/aws-sdk-go-v2/config v1.27.9/go.mod h1:dK1FQfpwpql83kbD873E9vz4FyAxuJtR22wzoXn3qq0= +github.com/aws/aws-sdk-go-v2/credentials v1.17.9 h1:N8s0/7yW+h8qR8WaRlPQeJ6czVMNQVNtNdUqf6cItao= +github.com/aws/aws-sdk-go-v2/credentials v1.17.9/go.mod h1:446YhIdmSV0Jf/SLafGZalQo+xr2iw7/fzXGDPTU1yQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.0 h1:af5YzcLf80tv4Em4jWVD75lpnOHSBkPUZxZfGkrI3HI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.0/go.mod h1:nQ3how7DMnFMWiU1SpECohgC82fpn4cKZ875NDMmwtA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 h1:0ScVK/4qZ8CIW0k8jOeFVsyS/sAiXpYxRBLolMkuLQM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4/go.mod h1:84KyjNZdHC6QZW08nfHI6yZgPd+qRgaWcYsyLUo3QY8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4 h1:sHmMWWX5E7guWEFQ9SVo6A3S4xpPrWnd77a6y4WM6PU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4/go.mod h1:WjpDrhWisWOIoS9n3nk67A3Ll1vfULJ9Kq6h29HTD48= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/ecr v1.24.7 h1:3iaT/LnGV6jNtbBkvHZDlzz7Ky3wMHDJAyFtGd5GUJI= +github.com/aws/aws-sdk-go-v2/service/ecr v1.24.7/go.mod h1:mtzCLxk6M+KZbkJdq3cUH9GCrudw8qCy5C3EHO+5vLc= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.21.6 h1:h+r5/diSwztgKgxUrntt6AOI5lBYY0ZJv+yzeulGZSU= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.21.6/go.mod h1:7+5MHFC52LC85xKCjCuWDHmIncOOvWnll10OT9EAN/g= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 h1:EyBZibRTVAs6ECHZOw5/wlylS9OcTzwyjeQMudmREjE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 h1:b+E7zIUHMmcB4Dckjpkapoy47W6C9QBv/zoUP+Hn8Kc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6/go.mod h1:S2fNV0rxrP78NhPbCZeQgY8H9jdDMeGtwcfZIRxzBqU= +github.com/aws/aws-sdk-go-v2/service/kms v1.30.0 h1:yS0JkEdV6h9JOo8sy2JSpjX+i7vsKifU8SIeHrqiDhU= +github.com/aws/aws-sdk-go-v2/service/kms v1.30.0/go.mod h1:+I8VUUSVD4p5ISQtzpgSva4I8cJ4SQ4b1dcBcof7O+g= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.3 h1:mnbuWHOcM70/OFUlZZ5rcdfA8PflGXXiefU/O+1S3+8= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.3/go.mod h1:5HFu51Elk+4oRBZVxmHrSds5jFXmFj8C3w7DVF2gnrs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3 h1:uLq0BKatTmDzWa/Nu4WO0M1AaQDaPpwTKAeByEc6WFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3/go.mod h1:b+qdhjnxj8GSR6t5YfphOffeoQSQ1KmpoVVuBn+PWxs= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 h1:J/PpTf/hllOjx8Xu9DMflff3FajfLxqM5+tepvVXmxg= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.5/go.mod h1:0ih0Z83YDH/QeQ6Ori2yGE2XvWYv/Xm+cZc01LC6oK0= +github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw= +github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20240116161626-88cfadc80e8f h1:mM9Ic3+hujxWGfpEf3E0fp12Lu7Xg2u2YsNb9WeliZQ= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20240116161626-88cfadc80e8f/go.mod h1:IPG+64HFPgPEx/vXYjqVpZ4lUgmzt1afdmi7ykS2Qjg= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng= +github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembjv71DPz3uX/V/6MMlSyD9JBQ6kQ= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/buildkite/agent/v3 v3.62.0 h1:yvzSjI8Lgifw883I8m9u8/L/Thxt4cLFd5aWPn3gg70= +github.com/buildkite/agent/v3 v3.62.0/go.mod h1:jN6SokGXrVNNIpI0BGQ+j5aWeI3gin8F+3zwA5Q6gqM= +github.com/buildkite/go-pipeline v0.3.2 h1:SW4EaXNwfjow7xDRPGgX0Rcx+dPj5C1kV9LKCLjWGtM= +github.com/buildkite/go-pipeline v0.3.2/go.mod h1:iY5jzs3Afc8yHg6KDUcu3EJVkfaUkd9x/v/OH98qyUA= +github.com/buildkite/interpolate v0.0.0-20200526001904-07f35b4ae251 h1:k6UDF1uPYOs0iy1HPeotNa155qXRWrzKnqAaGXHLZCE= +github.com/buildkite/interpolate v0.0.0-20200526001904-07f35b4ae251/go.mod h1:gbPR1gPu9dB96mucYIR7T3B7p/78hRVSOuzIWLHK2Y4= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA= +github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q= +github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= +github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= +github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= +github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= +github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/containerd/containerd v1.7.14 h1:H/XLzbnGuenZEGK+v0RkwTdv2u1QFAruMe5N0GNPJwA= +github.com/containerd/containerd v1.7.14/go.mod h1:YMC9Qt5yzNqXx/fO4j/5yYVIHXSRrlB3H7sxkUTvspg= +github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= +github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU= +github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk= +github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= +github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f h1:eHnXnuK47UlSTOQexbzxAZfekVz6i+LKRdj1CU5DPaM= +github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs= +github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/depcheck-test/depcheck-test v0.0.0-20220607135614-199033aaa936 h1:foGzavPWwtoyBvjWyKJYDYsyzy+23iBV7NKTwdk+LRY= +github.com/depcheck-test/depcheck-test v0.0.0-20220607135614-199033aaa936/go.mod h1:ttKPnOepYt4LLzD+loXQ1rT6EmpyIYHro7TAJuIIlHo= +github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg= +github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc= +github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= +github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v25.0.2+incompatible h1:6GEdvxwEA451/+Y3GtqIGn/MNjujQazUlxC6uGu8Tog= +github.com/docker/cli v25.0.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE= +github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo= +github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arXfYcAtECDFgAgHklGI8CxgjHnXKJ4= +github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU= +github.com/emicklei/go-restful/v3 v3.11.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY= +github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-piv/piv-go v1.11.0 h1:5vAaCdRTFSIW4PeqMbnsDlUZ7odMYWnHBDGdmtU/Zhg= +github.com/go-piv/piv-go v1.11.0/go.mod h1:NZ2zmjVkfFaL/CF8cVQ/pXdXtuj110zEKGdJM6fJZZM= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-rod/rod v0.114.7 h1:h4pimzSOUnw7Eo41zdJA788XsawzHjJMyzCE3BrBww0= +github.com/go-rod/rod v0.114.7/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= +github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= +github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.1.8 h1:LGYKkgZF7satzgTak9R4yzfJXEeYVAjV6/EAEJOf1to= +github.com/google/certificate-transparency-go v1.1.8/go.mod h1:bV/o8r0TBKRf1X//iiiSgWrvII4d7/8OiA+3vG26gI8= +github.com/google/flatbuffers v2.0.8+incompatible h1:ivUb1cGomAB101ZM1T0nOiWz9pSrTMoa9+EiY7igmkM= +github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= +github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.19.1 h1:yMQ62Al6/V0Z7CqIrrS1iYoA5/oQCm88DeNujc7C1KY= +github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI= +github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= +github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20231023181126-ff6d637d2a7b h1:RMpPgZTSApbPf7xaVel+QkoGPRLFLrwFO89uDUHEGf0= +github.com/google/pprof v0.0.0-20231023181126-ff6d637d2a7b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= +github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= +github.com/google/trillian v1.6.0 h1:jMBeDBIkINFvS2n6oV5maDqfRlxREAc6CW9QYWQ0qT4= +github.com/google/trillian v1.6.0/go.mod h1:Yu3nIMITzNhhMJEHjAtp6xKiu+H/iHu2Oq5FjV2mCWI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= +github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= +github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU= +github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= +github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.12.2 h1:7YkCTE5Ni90TcmYHDBExdt4WGJxhpzaHqR6uGbQb/rE= +github.com/hashicorp/vault/api v1.12.2/go.mod h1:LSGf1NGT1BnvFFnKVtnvcaLBM2Lz+gJdpL6HUYed8KE= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= +github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY15rFihtRfck/bfFqNfvcabqvXAFQfAUpY= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267/go.mod h1:h1nSAbGFqGVzn6Jyl1R/iCcBUHN4g+gW1u9CoBTrb9E= +github.com/jellydator/ttlcache/v3 v3.2.0 h1:6lqVJ8X3ZaUwvzENqPAobDsXNExfUJd61u++uW8a3LE= +github.com/jellydator/ttlcache/v3 v3.2.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= +github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/letsencrypt/boulder v0.0.0-20240205192639-0e9f5d35457f h1:/dN2ggCn3skxyhjrbMsWtsZPf21iupTfsogPxNyVIWM= +github.com/letsencrypt/boulder v0.0.0-20240205192639-0e9f5d35457f/go.mod h1:d3Z82ngfYLlWNMpnfwC+DmXBiZeF5G7gfIaefbf5ImI= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/miekg/pkcs11 v1.0.3-0.20190429190417-a667d056470f/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= +github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/51gbeLHfKGNfgEQQIWrlbdaOsidbQ= +github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= +github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/mozillazg/docker-credential-acr-helper v0.3.0 h1:DVWFZ3/O8BP6Ue3iS/Olw+G07u1hCq1EOVCDZZjCIBI= +github.com/mozillazg/docker-credential-acr-helper v0.3.0/go.mod h1:cZlu3tof523ujmLuiNUb6JsjtHcNA70u1jitrrdnuyA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= +github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481/go.mod h1:yKZQO8QE2bHlgozqWDiRVqTFlLQSj30K/6SAK8EeYFw= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oleiade/reflections v1.0.1 h1:D1XO3LVEYroYskEsoSiGItp9RUxG6jWnCVvrqH0HHQM= +github.com/oleiade/reflections v1.0.1/go.mod h1:rdFxbxq4QXVZWj0F+e9jqjDkc7dbp97vkRixKo2JR60= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/open-policy-agent/opa v0.63.0 h1:ztNNste1v8kH0/vJMJNquE45lRvqwrM5mY9Ctr9xIXw= +github.com/open-policy-agent/opa v0.63.0/go.mod h1:9VQPqEfoB2N//AToTxzZ1pVTVPUoF2Mhd64szzjWPpU= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= +github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.51.1 h1:eIjN50Bwglz6a/c3hAgSMcofL3nD+nFQkV6Dd4DsQCw= +github.com/prometheus/common v0.51.1/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6 h1:MAzmm+JtFxQwTPb1cVMLkemw2OxLy5AB/d/rxtAwGQQ= +github.com/protocolbuffers/txtpbfmt v0.0.0-20240116145035-ef3ab179eed6/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c= +github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/pterm/pterm v0.12.78 h1:QTWKaIAa4B32GKwqVXtu9m1DUMgWw3VRljMkMevX+b8= +github.com/pterm/pterm v0.12.78/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= +github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= +github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rubenv/sql-migrate v1.6.1 h1:bo6/sjsan9HaXAsNxYP/jCEDUGibHp8JmOBw7NTGRos= +github.com/rubenv/sql-migrate v1.6.1/go.mod h1:tPzespupJS0jacLfhbwto/UjSX+8h2FdWB7ar+QlHa0= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= +github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sigstore/cosign/v2 v2.2.4 h1:iY4vtEacmu2hkNj1Fh+8EBqBwKs2DHM27/lbNWDFJro= +github.com/sigstore/cosign/v2 v2.2.4/go.mod h1:JZlRD2uaEjVAvZ1XJ3QkkZJhTqSDVtLaet+C/TMR81Y= +github.com/sigstore/fulcio v1.4.5 h1:WWNnrOknD0DbruuZWCbN+86WRROpEl3Xts+WT2Ek1yc= +github.com/sigstore/fulcio v1.4.5/go.mod h1:oz3Qwlma8dWcSS/IENR/6SjbW4ipN0cxpRVfgdsjMU8= +github.com/sigstore/rekor v1.3.6 h1:QvpMMJVWAp69a3CHzdrLelqEqpTM3ByQRt5B5Kspbi8= +github.com/sigstore/rekor v1.3.6/go.mod h1:JDTSNNMdQ/PxdsS49DJkJ+pRJCO/83nbR5p3aZQteXc= +github.com/sigstore/sigstore v1.8.3 h1:G7LVXqL+ekgYtYdksBks9B38dPoIsbscjQJX/MGWkA4= +github.com/sigstore/sigstore v1.8.3/go.mod h1:mqbTEariiGA94cn6G3xnDiV6BD8eSLdL/eA7bvJ0fVs= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.3 h1:LTfPadUAo+PDRUbbdqbeSl2OuoFQwUFTnJ4stu+nwWw= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.3/go.mod h1:QV/Lxlxm0POyhfyBtIbTWxNeF18clMlkkyL9mu45y18= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.3 h1:xgbPRCr2npmmsuVVteJqi/ERw9+I13Wou7kq0Yk4D8g= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.3/go.mod h1:G4+I83FILPX6MtnoaUdmv/bRGEVtR3JdLeJa/kXdk/0= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.3 h1:vDl2fqPT0h3D/k6NZPlqnKFd1tz3335wm39qjvpZNJc= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.3/go.mod h1:9uOJXbXEXj+M6QjMKH5PaL5WDMu43rHfbIMgXzA8eKI= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.3 h1:h9G8j+Ds21zqqulDbA/R/ft64oQQIyp8S7wJYABYSlg= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.3/go.mod h1:zgCeHOuqF6k7A7TTEvftcA9V3FRzB7mrPtHOhXAQBnc= +github.com/sigstore/timestamp-authority v1.2.2 h1:X4qyutnCQqJ0apMewFyx+3t7Tws00JQ/JonBiu3QvLE= +github.com/sigstore/timestamp-authority v1.2.2/go.mod h1:nEah4Eq4wpliDjlY342rXclGSO7Kb9hoRrl9tqLW13A= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= +github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/spiffe/go-spiffe/v2 v2.2.0 h1:9Vf06UsvsDbLYK/zJ4sYsIsHmMFknUD+feA7IYoWMQY= +github.com/spiffe/go-spiffe/v2 v2.2.0/go.mod h1:Urzb779b3+IwDJD2ZbN8fVl3Aa8G4N/PiUe6iXC0XxU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= +github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= +github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= +github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w= +github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts= +github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/vmware-tanzu/carvel-imgpkg v0.38.3 h1:vVnqCPFEZ2NQcoTywg/va91qRyCuu46wBYAETqoyez4= +github.com/vmware-tanzu/carvel-imgpkg v0.38.3/go.mod h1:v9BcO1qfXwwIQFw2zmksdUkx8eI1e+/a0Md3xG2BzDE= +github.com/xanzy/go-gitlab v0.102.0 h1:ExHuJ1OTQ2yt25zBMMj0G96ChBirGYv8U7HyUiYkZ+4= +github.com/xanzy/go-gitlab v0.102.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.34.1 h1:IrV2uWLs45VXNvZqhJ6g2nIhY+pgIG1CUoOcqfXFl1s= +github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.8.0 h1:BzLrVoiwxikpgEQR0Lk8NyBN5Cit2b1z+u0mgL4ZJak= +github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= +github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0/go.mod h1:noq80iT8rrHP1SfybmPiRGc9dc5M8RPmGvtwo7Oo7tc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.22.0 h1:H2JFgRcGiyHg7H7bwcwaQJYrNFqCqrbTQ8K4p1OvDu8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.22.0/go.mod h1:WfCWp1bGoYK8MeULtI15MmQVczfR+bFkk0DF3h06QmQ= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.starlark.net v0.0.0-20240123142251-f86470692795 h1:LmbG8Pq7KDGkglKVn8VpZOZj6vb9b8nKEGcg9l03epM= +go.starlark.net v0.0.0-20240123142251-f86470692795/go.mod h1:LcLNIzVOMp4oV+uusnpk+VU+SzXaJakUuBjoCSWH5dM= +go.step.sm/crypto v0.44.2 h1:t3p3uQ7raP2jp2ha9P6xkQF85TJZh+87xmjSLaib+jk= +go.step.sm/crypto v0.44.2/go.mod h1:x1439EnFhadzhkuaGX7sz03LEMQ+jV4gRamf5LCZJQQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg= +golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/api v0.172.0 h1:/1OcMZGPmW1rX2LCu2CmGUD1KXK1+pfzxotxyRUCCdk= +google.golang.org/api v0.172.0/go.mod h1:+fJZq6QXWfa9pXhnIzsjx4yI22d4aI9ZpLb58gvXjis= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20240311173647-c811ad7063a7 h1:ImUcDPHjTrAqNhlOkSocDLfG9rrNHH7w7uoKWPaWZ8s= +google.golang.org/genproto v0.0.0-20240311173647-c811ad7063a7/go.mod h1:/3XmxOjePkvmKrHuBy4zNFw7IzxJXtAgdpXi8Ll990U= +google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7 h1:oqta3O3AnlWbmIE3bFnWbu4bRxZjfbWCp0cKSuZh01E= +google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7/go.mod h1:VQW3tUculP/D4B+xVCo+VgSq8As6wA9ZjHl//pmk+6s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v5 v5.9.0 h1:hx1VU2SGj4F8r9b8GUwJLdc8DNO8sy79ZGui0G05GLo= +gopkg.in/evanphx/json-patch.v5 v5.9.0/go.mod h1:/kvTRh1TVm5wuM6OkHxqXtE/1nUZZpihg29RtuIyfvk= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= +gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +helm.sh/helm/v3 v3.14.2 h1:V71fv+NGZv0icBlr+in1MJXuUIHCiPG1hW9gEBISTIA= +helm.sh/helm/v3 v3.14.2/go.mod h1:2itvvDv2WSZXTllknfQo6j7u3VVgMAvm8POCDgYH424= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.29.1 h1:DAjwWX/9YT7NQD4INu49ROJuZAAAP/Ijki48GUPzxqw= +k8s.io/api v0.29.1/go.mod h1:7Kl10vBRUXhnQQI8YR/R327zXC8eJ7887/+Ybta+RoQ= +k8s.io/apiextensions-apiserver v0.29.1 h1:S9xOtyk9M3Sk1tIpQMu9wXHm5O2MX6Y1kIpPMimZBZw= +k8s.io/apiextensions-apiserver v0.29.1/go.mod h1:zZECpujY5yTW58co8V2EQR4BD6A9pktVgHhvc0uLfeU= +k8s.io/apimachinery v0.29.1 h1:KY4/E6km/wLBguvCZv8cKTeOwwOBqFNjwJIdMkMbbRc= +k8s.io/apimachinery v0.29.1/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= +k8s.io/apiserver v0.29.1 h1:e2wwHUfEmMsa8+cuft8MT56+16EONIEK8A/gpBSco+g= +k8s.io/apiserver v0.29.1/go.mod h1:V0EpkTRrJymyVT3M49we8uh2RvXf7fWC5XLB0P3SwRw= +k8s.io/cli-runtime v0.29.1 h1:By3WVOlEWYfyxhGko0f/IuAOLQcbBSMzwSaDren2JUs= +k8s.io/cli-runtime v0.29.1/go.mod h1:vjEY9slFp8j8UoMhV5AlO8uulX9xk6ogfIesHobyBDU= +k8s.io/client-go v0.29.1 h1:19B/+2NGEwnFLzt0uB5kNJnfTsbV8w6TgQRz9l7ti7A= +k8s.io/client-go v0.29.1/go.mod h1:TDG/psL9hdet0TI9mGyHJSgRkW3H9JZk2dNEUS7bRks= +k8s.io/component-base v0.29.1 h1:MUimqJPCRnnHsskTTjKD+IC1EHBbRCVyi37IoFBrkYw= +k8s.io/component-base v0.29.1/go.mod h1:fP9GFjxYrLERq1GcWWZAE3bqbNcDKDytn2srWuHTtKc= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240126223410-2919ad4fcfec h1:iGTel2aR8vCZdxJDgmbeY0zrlXy9Qcvyw4R2sB4HLrA= +k8s.io/kube-openapi v0.0.0-20240126223410-2919ad4fcfec/go.mod h1:Pa1PvrP7ACSkuX6I7KYomY6cmMA0Tx86waBhDUgoKPw= +k8s.io/kubectl v0.29.1 h1:rWnW3hi/rEUvvg7jp4iYB68qW5un/urKbv7fu3Vj0/s= +k8s.io/kubectl v0.29.1/go.mod h1:SZzvLqtuOJYSvZzPZR9weSuP0wDQ+N37CENJf0FhDF4= +k8s.io/utils v0.0.0-20240102154912-e7106e64919e h1:eQ/4ljkx21sObifjzXwlPKpdGLrCfRziVtos3ofG/sQ= +k8s.io/utils v0.0.0-20240102154912-e7106e64919e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= +oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= +oras.land/oras-go/v2 v2.4.0 h1:i+Wt5oCaMHu99guBD0yuBjdLvX7Lz8ukPbwXdR7uBMs= +oras.land/oras-go/v2 v2.4.0/go.mod h1:osvtg0/ClRq1KkydMAEu/IxFieyjItcsQ4ut4PPF+f8= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/kustomize/api v0.16.0 h1:/zAR4FOQDCkgSDmVzV2uiFbuy9bhu3jEzthrHCuvm1g= +sigs.k8s.io/kustomize/api v0.16.0/go.mod h1:MnFZ7IP2YqVyVwMWoRxPtgl/5hpA+eCCrQR/866cm5c= +sigs.k8s.io/kustomize/kyaml v0.16.0 h1:6J33uKSoATlKZH16unr2XOhDI+otoe2sR3M8PDzW3K0= +sigs.k8s.io/kustomize/kyaml v0.16.0/go.mod h1:xOK/7i+vmE14N2FdFyugIshB8eF6ALpy7jI87Q2nRh4= +sigs.k8s.io/release-utils v0.7.7 h1:JKDOvhCk6zW8ipEOkpTGDH/mW3TI+XqtPp16aaQ79FU= +sigs.k8s.io/release-utils v0.7.7/go.mod h1:iU7DGVNi3umZJ8q6aHyUFzsDUIaYwNnNKGHo3YE5E3s= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/install-binary.sh b/install-binary.sh new file mode 100755 index 0000000..ed2e938 --- /dev/null +++ b/install-binary.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env sh + +PROJECT_NAME="distribution-tooling-for-helm" +BINARY_NAME="dt" +PROJECT_GH="vmware-labs/$PROJECT_NAME" +PLUGIN_MANIFEST="plugin.yaml" + +# Convert HELM_BIN and HELM_PLUGIN_DIR to unix if cygpath is +# available. This is the case when using MSYS2 or Cygwin +# on Windows where helm returns a Windows path but we +# need a Unix path + +if command -v cygpath >/dev/null 2>&1; then + HELM_BIN="$(cygpath -u "${HELM_BIN}")" + HELM_PLUGIN_DIR="$(cygpath -u "${HELM_PLUGIN_DIR}")" +fi + +[ -z "$HELM_BIN" ] && HELM_BIN=$(command -v helm) + +[ -z "$HELM_HOME" ] && HELM_HOME=$(helm env | grep 'HELM_DATA_HOME' | cut -d '=' -f2 | tr -d '"') + +mkdir -p "$HELM_HOME" + +if [ "$SKIP_BIN_INSTALL" = "1" ]; then + echo "Skipping binary install" + exit +fi + +# which mode is the common installer script running in +SCRIPT_MODE="install" +if [ "$1" = "-u" ]; then + SCRIPT_MODE="update" +fi + +# initArch discovers the architecture for this system. +initArch() { + ARCH=$(uname -m) + case $ARCH in + armv5*) ARCH="armv5" ;; + armv6*) ARCH="armv6" ;; + armv7*) ARCH="armv7" ;; + aarch64) ARCH="arm64" ;; + x86) ARCH="386" ;; + x86_64) ARCH="amd64" ;; + i686) ARCH="386" ;; + i386) ARCH="386" ;; + esac +} + +# initOS discovers the operating system for this system. +initOS() { + OS=$(uname -s) + + case "$OS" in + Windows_NT) OS='windows' ;; + # Msys support + MSYS*) OS='windows' ;; + # Minimalist GNU for Windows + MINGW*) OS='windows' ;; + CYGWIN*) OS='windows' ;; + Darwin) OS='darwin' ;; + Linux) OS='linux' ;; + esac +} + +# verifySupported checks that the os/arch combination is supported for +# binary builds. +verifySupported() { + supported="linux-amd64\nlinux-arm64\nfreebsd-amd64\ndarwin-amd64\ndarwin-arm64\nwindows-amd64" + if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then + echo "No prebuild binary for ${OS}-${ARCH}." + exit 1 + fi + + if + ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1 + then + echo "Either curl or wget is required" + exit 1 + fi +} + +# getDownloadURL checks the latest available version. +getDownloadURL() { + version="$(< "$HELM_PLUGIN_DIR/$PLUGIN_MANIFEST" grep "version" | cut -d '"' -f 2)" + ext="tar.gz" + if [ "$OS" = "windows" ]; then + ext="zip" + fi + if [ "$SCRIPT_MODE" = "install" ] && [ -n "$version" ]; then + DOWNLOAD_URL="https://github.com/${PROJECT_GH}/releases/download/v${version}/${PROJECT_NAME}_${version}_${OS}_${ARCH}.${ext}" + else + DOWNLOAD_URL="https://github.com/${PROJECT_GH}/releases/latest/download/${PROJECT_NAME}_${version}_${OS}_${ARCH}.${ext}" + fi +} + +# Temporary dir +mkTempDir() { + HELM_TMP="$(mktemp -d -t "${PROJECT_NAME}-XXXXXX")" +} +rmTempDir() { + if [ -d "${HELM_TMP:-/tmp/distribution-tooling-for-helm-tmp}" ]; then + rm -rf "${HELM_TMP:-/tmp/distribution-tooling-for-helm-tmp}" + fi +} + +# downloadFile downloads the latest binary package and also the checksum +# for that binary. +downloadFile() { + PLUGIN_TMP_FILE="${HELM_TMP}/${PROJECT_NAME}.tgz" + echo "Downloading $DOWNLOAD_URL" + if + command -v curl >/dev/null 2>&1 + then + curl -sSf -L "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" + elif + command -v wget >/dev/null 2>&1 + then + wget -q -O - "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" + fi +} + +# installFile verifies the SHA256 for the file, then unpacks and +# installs it. +installFile() { + HELM_TMP_BIN="$HELM_TMP/$BINARY_NAME" + if [ "${OS}" = "windows" ]; then + HELM_TMP_BIN="$HELM_TMP_BIN.exe" + unzip "$PLUGIN_TMP_FILE" -d "$HELM_TMP" + else + tar xzf "$PLUGIN_TMP_FILE" -C "$HELM_TMP" + fi + echo "Preparing to install into ${HELM_PLUGIN_DIR}" + mkdir -p "$HELM_PLUGIN_DIR/bin" + cp "$HELM_TMP_BIN" "$HELM_PLUGIN_DIR/bin" +} + +# exit_trap is executed if on exit (error or not). +exit_trap() { + result=$? + rmTempDir + if [ "$result" != "0" ]; then + echo "Failed to install $PROJECT_NAME" + printf "\tFor support, go to https://github.com/%s.\n" "$PROJECT_GH" + fi + exit $result +} + +# testVersion tests the installed client to make sure it is working. +testVersion() { + set +e + echo "$PROJECT_NAME installed into $HELM_PLUGIN_DIR" + "${HELM_PLUGIN_DIR}/bin/$BINARY_NAME" -h + set -e +} + +# Execution + +#Stop execution on any error +trap "exit_trap" EXIT +set -e +initArch +initOS +verifySupported +getDownloadURL +mkTempDir +downloadFile +installFile +testVersion diff --git a/internal/testutil/assert.go b/internal/testutil/assert.go new file mode 100644 index 0000000..f801870 --- /dev/null +++ b/internal/testutil/assert.go @@ -0,0 +1,105 @@ +package testutil + +import ( + "fmt" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v2" + + "helm.sh/helm/v3/pkg/chart/loader" +) + +// Measure executes fn and returns the time taken for it to finish +func Measure(fn func()) time.Duration { + t1 := time.Now() + fn() + t2 := time.Now() + return t2.Sub(t1) +} + +func functionAborted(fn func()) (bool, string) { + aborted := false + msg := "" + func() { + defer func() { + if r := recover(); r != nil { + aborted = true + switch v := r.(type) { + case string: + msg = v + case fmt.Stringer: + msg = v.String() + default: + msg = fmt.Sprintf("%v", v) + } + } + }() + fn() + }() + return aborted, msg +} + +// AssertFileExists failts the test t if path does not exists +func AssertFileExists(t *testing.T, path string, msgAndArgs ...interface{}) bool { + fullPath, _ := filepath.Abs(path) + if fileExists(fullPath) { + return true + } + assert.Fail(t, fmt.Sprintf("File '%s' should exist", path), msgAndArgs...) + return false +} + +// AssertFileDoesNotExist failts the test t if path exists +func AssertFileDoesNotExist(t *testing.T, path string, msgAndArgs ...interface{}) bool { + fullPath, _ := filepath.Abs(path) + if !fileExists(fullPath) { + return true + } + assert.Fail(t, fmt.Sprintf("File '%s' should not exist", path), msgAndArgs...) + return false +} + +// AssertPanicsMatch fails the test t if fn does not panic or if the panic +// message does not match the provided regexp re +func AssertPanicsMatch(t *testing.T, fn func(), re *regexp.Regexp, msgAndArgs ...interface{}) bool { + if assert.Panics(t, fn, msgAndArgs...) { + _, msg := functionAborted(fn) + return assert.Regexp(t, re, msg, msgAndArgs...) + } + return false +} + +// AssertErrorMatch fails the test t if err is nil or if its message +// does not match the provided regexp re +func AssertErrorMatch(t *testing.T, err error, re *regexp.Regexp, msgAndArgs ...interface{}) bool { + if assert.Error(t, err, msgAndArgs...) { + return assert.Regexp(t, re, err.Error(), msgAndArgs...) + } + return false +} + +// AnnotationEntry defines an annotation entry +type AnnotationEntry struct { + Name string + Image string +} + +// AssertChartAnnotations checks if the specified chart contains the provided annotations +func AssertChartAnnotations(t *testing.T, chartDir string, annotationsKey string, expectedImages []AnnotationEntry, msgAndArgs ...interface{}) bool { + c, err := loader.Load(chartDir) + if err != nil { + assert.Fail(t, fmt.Sprintf("Failed to load chart %q: %v", chartDir, err)) + return false + } + + gotImages := make([]AnnotationEntry, 0) + if err := yaml.Unmarshal([]byte(c.Metadata.Annotations[annotationsKey]), &gotImages); err != nil { + assert.Fail(t, fmt.Sprintf("Failed to unmarshal chart annotations: %v", err)) + return false + } + return assert.EqualValues(t, expectedImages, gotImages, msgAndArgs...) +} diff --git a/internal/testutil/assert_test.go b/internal/testutil/assert_test.go new file mode 100644 index 0000000..6e36b70 --- /dev/null +++ b/internal/testutil/assert_test.go @@ -0,0 +1,87 @@ +package testutil + +import ( + "fmt" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMeasure(t *testing.T) { + delay := 500 * time.Millisecond + t1 := time.Now() + ellapsed := Measure(func() { + time.Sleep(delay) + }) + t2 := time.Now() + assert.WithinDuration(t, t1.Add(ellapsed), t2, 10*time.Millisecond, "Measured time %v is out of the acceptable ranges", ellapsed) +} + +func TestAssertFileExistsAndDoesnttExist(t *testing.T) { + var sampleT *testing.T + sb := NewSandbox() + defer sb.Cleanup() + + sampleT = &testing.T{} + AssertFileExists(sampleT, "foo") + assert.True(t, sampleT.Failed()) + + sampleT = &testing.T{} + AssertFileDoesNotExist(sampleT, "foo") + assert.False(t, sampleT.Failed()) + + sampleT = &testing.T{} + AssertFileExists(sampleT, sb.Root) + assert.False(t, sampleT.Failed()) + + sampleT = &testing.T{} + AssertFileDoesNotExist(sampleT, sb.Root) + assert.True(t, sampleT.Failed()) +} + +func TestAssertPanicsMatch(t *testing.T) { + var sampleT *testing.T + sampleT = &testing.T{} + AssertPanicsMatch(sampleT, func() { + // no panic + }, regexp.MustCompile(".*")) + assert.True(t, sampleT.Failed()) + + sampleT = &testing.T{} + AssertPanicsMatch(sampleT, func() { + // wrong panic message + panic("Wrong error") + }, regexp.MustCompile("Unexpected error.*")) + assert.True(t, sampleT.Failed()) + + sampleT = &testing.T{} + AssertPanicsMatch(sampleT, func() { + // Matching error + panic("Unexpected error in test") + }, regexp.MustCompile("Unexpected error.*")) + assert.False(t, sampleT.Failed()) +} + +func TestAssertErrorMatch(t *testing.T) { + var sampleT *testing.T + sampleT = &testing.T{} + // no error + AssertErrorMatch(sampleT, nil, regexp.MustCompile(".*")) + assert.True(t, sampleT.Failed()) + + sampleT = &testing.T{} + // wrong error message + AssertErrorMatch(sampleT, + fmt.Errorf("Wrong error"), + regexp.MustCompile("Unexpected error.*")) + assert.True(t, sampleT.Failed()) + + sampleT = &testing.T{} + // Matching error + AssertErrorMatch(sampleT, + fmt.Errorf("Unexpected error in test"), + regexp.MustCompile("Unexpected error.*")) + assert.False(t, sampleT.Failed()) +} diff --git a/internal/testutil/cosign.go b/internal/testutil/cosign.go new file mode 100644 index 0000000..26fa50d --- /dev/null +++ b/internal/testutil/cosign.go @@ -0,0 +1,135 @@ +package testutil + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "strings" + + "github.com/DataDog/go-tuf/encrypted" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sigstore/cosign/v2/cmd/cosign/cli/options" + "github.com/sigstore/cosign/v2/cmd/cosign/cli/sign" + "github.com/sigstore/cosign/v2/cmd/cosign/cli/verify" + "github.com/sigstore/cosign/v2/pkg/cosign" +) + +// resolveImage gets a image and returns its resolved tag version +func resolveImage(image string, opts ...crane.Option) (string, error) { + o := crane.GetOptions(opts...) + + ref, err := name.ParseReference(image) + if err != nil { + return "", fmt.Errorf("failed to parse image reference: %w", err) + } + + switch v := ref.(type) { + case name.Tag: + desc, err := remote.Get(ref, o.Remote...) + if err != nil { + return "", fmt.Errorf("failed to get remote descriptor: %w", err) + } + return fmt.Sprintf("%s@%s", ref.Context().Name(), desc.Digest), nil + case name.Digest: + // We already got a digest + return image, nil + default: + return "", fmt.Errorf("unsupported reference type %T", v) + } +} + +// CosignImage signs a remote artifact with the provided key +func CosignImage(url string, key string, opts ...crane.Option) error { + o := crane.GetOptions(opts...) + url = strings.TrimPrefix(url, "oci://") + // cosign complains if we sign a tag with + // WARNING: Image reference 127.0.0.1/test:mytag uses a tag, not a digest, to identify the image to sign. + image, err := resolveImage(url, opts...) + if err != nil { + return fmt.Errorf("failed to sign %q: %v", url, err) + } + return sign.SignCmd( + &options.RootOptions{Timeout: options.DefaultTimeout, Verbose: false}, + options.KeyOpts{KeyRef: key}, + options.SignOptions{Upload: true, Registry: options.RegistryOptions{RegistryClientOpts: o.Remote}}, + []string{image}, + ) +} + +// CosignVerifyImage verifies a remote artifact signature with the provided key +func CosignVerifyImage(url string, key string, opts ...crane.Option) error { + o := crane.GetOptions(opts...) + url = strings.TrimPrefix(url, "oci://") + + v := &verify.VerifyCommand{ + RegistryOptions: options.RegistryOptions{RegistryClientOpts: o.Remote}, + KeyRef: key, + IgnoreTlog: true, + } + v.NameOptions = append(v.NameOptions, name.Insecure) + ctx := context.Background() + return v.Exec(ctx, []string{url}) +} + +func writeTempFile(dir, name string, data []byte) (*os.File, error) { + fh, err := os.CreateTemp(dir, name) + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %v", err) + } + defer fh.Close() + if _, err := fh.Write(data); err != nil { + return nil, err + } + return fh, nil +} + +// GenerateCosignCertificateFiles generates sample signing keys for usage with cosign +func GenerateCosignCertificateFiles(tmpDir string) (privFile, pubFile string, err error) { + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + encodedPub, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return "", "", fmt.Errorf("failed to encode public key: %v", err) + + } + encodedPriv, err := x509.MarshalPKCS8PrivateKey(privKey) + if err != nil { + return "", "", fmt.Errorf("failed to encode private key: %v", err) + } + + password := []byte{} + + encryptedPrivBytes, err := encrypted.Encrypt(encodedPriv, password) + if err != nil { + return "", "", fmt.Errorf("failed to encrypt key: %v", err) + } + + privKeyFile, err := writeTempFile(tmpDir, "cosign_test_*.key", pem.EncodeToMemory(&pem.Block{ + Bytes: encryptedPrivBytes, + Type: cosign.CosignPrivateKeyPemType, + })) + if err != nil { + return "", "", fmt.Errorf("failed to create temp key file: %v", err) + } + + pubKeyFile, err := writeTempFile(tmpDir, "cosign_test_*.pub", pem.EncodeToMemory(&pem.Block{ + Bytes: encodedPub, + Type: "PUBLIC KEY", + })) + + if err != nil { + return "", "", fmt.Errorf("failed to write pub key file: %v", err) + } + + return privKeyFile.Name(), pubKeyFile.Name(), nil + +} diff --git a/internal/testutil/oci.go b/internal/testutil/oci.go new file mode 100644 index 0000000..dfed4f6 --- /dev/null +++ b/internal/testutil/oci.go @@ -0,0 +1,323 @@ +package testutil + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + "oras.land/oras-go/v2/content/oci" + + "helm.sh/helm/v3/pkg/repo/repotest" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/registry" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/remote" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/phayes/freeport" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/file" +) + +func parseFileRef(reference string, defaultMetadata string) (filePath, metadata string, err error) { + i := strings.LastIndex(reference, ":") + if i < 0 { + filePath, metadata = reference, defaultMetadata + } else { + filePath, metadata = reference[:i], reference[i+1:] + } + if filePath == "" { + return "", "", fmt.Errorf("found empty file path in %q", reference) + } + return filePath, metadata, nil +} + +func listFilesRecursively(dirPath string) ([]string, error) { + + var files []string + + info, err := os.Stat(dirPath) + if err != nil { + return nil, err + } + + if !info.IsDir() { + files = append(files, dirPath) + return files, nil + } + + if err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + files = append(files, path) + } + return nil + }); err != nil { + return nil, err + } + + return files, nil +} +func loadDir(ctx context.Context, store *file.Store, annotations map[string]map[string]string, dir string) ([]ocispec.Descriptor, error) { + files, err := listFilesRecursively(dir) + if err != nil { + return nil, err + } + return loadFiles(ctx, store, annotations, files, dir) +} + +func loadFiles(ctx context.Context, store *file.Store, annotations map[string]map[string]string, fileRefs []string, rootDir string) ([]ocispec.Descriptor, error) { + var files []ocispec.Descriptor + for _, fileRef := range fileRefs { + filename, mediaType, err := parseFileRef(fileRef, "") + if err != nil { + return nil, err + } + + name := filepath.Clean(filename) + if !filepath.IsAbs(name) { + name = filepath.ToSlash(name) + } + if rootDir != "" { + name, err = filepath.Rel(rootDir, name) + if err != nil { + return nil, err + } + } + + file, err := store.Add(ctx, name, mediaType, filename) + if err != nil { + return nil, err + } + if value, ok := annotations[filename]; ok { + if file.Annotations == nil { + file.Annotations = value + } else { + for k, v := range value { + file.Annotations[k] = v + } + } + } + files = append(files, file) + } + + return files, nil +} + +// UnpackOCILayout takes an oci-layout directory and extracts its artifacts to the destDir +func UnpackOCILayout(ctx context.Context, srcLayout string, destDir string) error { + src, err := oci.NewFromFS(ctx, os.DirFS(srcLayout)) + if err != nil { + return err + } + + l, err := layout.ImageIndexFromPath(srcLayout) + if err != nil { + return err + } + man, err := l.IndexManifest() + if err != nil { + return err + } + + if len(man.Manifests) > 1 { + return fmt.Errorf("found too many manifests (expected 1)") + } + + tag := man.Manifests[0].Digest.String() + + dest, err := file.New(destDir) + if err != nil { + return err + } + defer dest.Close() + + if _, err := oras.Copy(ctx, src, tag, dest, "", oras.DefaultCopyOptions); err != nil { + return err + } + + return nil +} + +// CreateOCILayout creates a oc-layout directory from a source directory containing a set of files +func CreateOCILayout(ctx context.Context, srcDir, destDir string) error { + + dest, err := oci.New(destDir) + + if err != nil { + return err + } + store, err := file.New("") + if err != nil { + return err + } + defer store.Close() + + packOpts := oras.PackManifestOptions{} + + descs, err := loadDir(ctx, store, nil, srcDir) + if err != nil { + return err + } + + packOpts.Layers = descs + + pack := func() (ocispec.Descriptor, error) { + root, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_1_RC4, oras.MediaTypeUnknownArtifact, packOpts) + if err != nil { + return ocispec.Descriptor{}, err + } + if err = store.Tag(ctx, root, root.Digest.String()); err != nil { + return ocispec.Descriptor{}, err + } + return root, nil + } + root, err := pack() + if err != nil { + return err + } + err = oras.CopyGraph(context.Background(), store, dest, root, oras.CopyGraphOptions{}) + + if err != nil { + return err + } + return err +} + +func pushArtifact(ctx context.Context, image string, dir string, opts ...crane.Option) error { + options := []crane.Option{crane.WithContext(ctx)} + options = append(options, opts...) + + img, err := loadImage(dir) + if err != nil { + return err + } + + switch t := img.(type) { + case v1.Image: + return crane.Push(t, image, options...) + default: + return fmt.Errorf("unsupported image type %T", t) + } +} + +// PullArtifact downloads an artifact from a remote oci into a oci-layout +func PullArtifact(ctx context.Context, src string, dir string, opts ...crane.Option) error { + craneOpts := []crane.Option{crane.WithContext(ctx)} + craneOpts = append(craneOpts, opts...) + o := crane.GetOptions(craneOpts...) + + ref, err := name.ParseReference(src, o.Name...) + + if err != nil { + return fmt.Errorf("failed to parse reference %q: %w", src, err) + } + desc, err := remote.Get(ref, o.Remote...) + if err != nil { + return fmt.Errorf("failed to get remote descriptor: %w", err) + } + + img, err := desc.Image() + if err != nil { + return err + } + + if err := crane.SaveOCI(img, dir); err != nil { + return fmt.Errorf("failed to save image: %v", err) + } + return nil +} + +func loadImage(path string) (partial.WithRawManifest, error) { + stat, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !stat.IsDir() { + return nil, fmt.Errorf("expected %q to be a directory", path) + } + + l, err := layout.ImageIndexFromPath(path) + if err != nil { + return nil, fmt.Errorf("loading %s as OCI layout: %w", path, err) + } + + m, err := l.IndexManifest() + if err != nil { + return nil, err + } + if len(m.Manifests) != 1 { + return nil, fmt.Errorf("layout contains multiple entries (%d)", len(m.Manifests)) + } + + desc := m.Manifests[0] + return l.Image(desc.Digest) +} + +// NewOCIServer returns a new OCI server with basic auth for testing purposes +func NewOCIServer(t *testing.T, dir string) (*repotest.OCIServer, error) { + return NewOCIServerWithCustomCreds(t, dir, "username", "password") +} + +// NewOCIServerWithCustomCreds returns a new OCI server with custom credentials +func NewOCIServerWithCustomCreds(t *testing.T, dir string, username, password string) (*repotest.OCIServer, error) { + testHtpasswdFileBasename := "authtest.htpasswd" + testUsername, testPassword := username, password + + pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) + if err != nil { + t.Fatal("error generating bcrypt password for test htpasswd file") + } + htpasswdPath := filepath.Join(dir, testHtpasswdFileBasename) + err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644) + if err != nil { + t.Fatalf("error creating test htpasswd file") + } + + // Registry config + config := &configuration.Configuration{} + port, err := freeport.GetFreePort() + if err != nil { + t.Fatalf("error finding free port for test registry") + } + + config.HTTP.Addr = fmt.Sprintf(":%d", port) + config.HTTP.DrainTimeout = time.Duration(10) * time.Second + config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} + config.Auth = configuration.Auth{ + "htpasswd": configuration.Parameters{ + "realm": "localhost", + "path": htpasswdPath, + }, + } + config.Log.AccessLog.Disabled = true + config.Log.Formatter = "json" + config.Log.Level = "panic" + + registryURL := fmt.Sprintf("localhost:%d", port) + + r, err := registry.NewRegistry(context.Background(), config) + if err != nil { + t.Fatal(err) + } + + return &repotest.OCIServer{ + Registry: r, + RegistryURL: registryURL, + TestUsername: testUsername, + TestPassword: testPassword, + Dir: dir, + }, nil +} diff --git a/internal/testutil/sandbox.go b/internal/testutil/sandbox.go new file mode 100644 index 0000000..e4c5263 --- /dev/null +++ b/internal/testutil/sandbox.go @@ -0,0 +1,152 @@ +package testutil + +import ( + "log" + "math/rand" + "os" + "path/filepath" + "strconv" + "sync" + "time" +) + +var ( + tempFileIndex = 0 + mutex = &sync.Mutex{} +) + +// Sandbox allows manipulating files and directories with paths sandboxed into +// the Root directory +type Sandbox struct { + sync.RWMutex + // Root of the sandbox + Root string + temporaryResources []string +} + +// NewSandbox returns a new sandbox with the configured root or a random +// temporary one if none is provided +func NewSandbox(args ...string) *Sandbox { + var root string + var err error + if len(args) > 0 { + root = args[0] + } else { + root, err = os.MkdirTemp("", "sandbox") + if err != nil { + log.Fatal("Error creating temporary directory for sandbox") + } + } + sb := &Sandbox{Root: root} + sb.temporaryResources = make([]string, 0) + return sb +} + +// Track registers a path as a temporary one to be deleted on cleanup +func (sb *Sandbox) Track(p string) { + defer sb.Unlock() + sb.Lock() + sb.temporaryResources = append(sb.temporaryResources, sb.Normalize(p)) +} + +// Touch touches a file inside the sandbox +func (sb *Sandbox) Touch(file string) string { + f := sb.Normalize(file) + if fileExists(f) { + os.Chtimes(f, time.Now(), time.Now()) + } else { + sb.WriteFile(f, []byte{}, os.FileMode(0766)) + } + return f +} + +// TempFile returns a temporary non-existent file. +// An optional file tail can be provided +func (sb *Sandbox) TempFile(args ...string) string { + tail := "" + if len(args) > 0 { + tail = args[0] + } else { + tail = strconv.Itoa(rand.Int()) + // Too long paths in osx result in errors creating sockets (make the daemon tests break) + // https://github.com/golang/go/issues/6895 + if len(tail) > 10 { + tail = tail[0:10] + } + } + mutex.Lock() + tail += strconv.Itoa(tempFileIndex) + tempFileIndex++ + mutex.Unlock() + + f := sb.Normalize(tail) + if fileExists(f) { + suffix := 0 + for fileExists(f + strconv.Itoa(suffix)) { + suffix++ + } + f = f + strconv.Itoa(suffix) + } + + sb.Track(f) + return f +} + +// Mkdir creates a directory inside the sandbox +func (sb *Sandbox) Mkdir(p string, mode os.FileMode) (string, error) { + f := sb.Normalize(p) + sb.Track(f) + return f, os.MkdirAll(f, mode) +} + +// Symlink creates a symlink inside the sandbox +func (sb *Sandbox) Symlink(oldname, newname string) (string, error) { + dest := sb.Normalize(newname) + sb.Track(dest) + return dest, os.Symlink(oldname, dest) +} + +// Write writes data into the file pointed by path. +// This is a convenience wrapper around WriteFile +func (sb *Sandbox) Write(path string, data string) (string, error) { + return sb.WriteFile(path, []byte(data), os.FileMode(0644)) +} + +// WriteFile writes a set of bytes (data) into the file pointed by path and with the specified mode +func (sb *Sandbox) WriteFile(path string, data []byte, mode os.FileMode) (string, error) { + f := sb.Normalize(path) + sb.Track(f) + return f, os.WriteFile(f, data, mode) +} + +// Cleanup removes all the resources created by the sandbox +func (sb *Sandbox) Cleanup() error { + sb.RLock() + resources := sb.temporaryResources + sb.RUnlock() + for _, p := range resources { + os.RemoveAll(p) + } + return os.RemoveAll(sb.Root) +} + +// ContainsPath returns true if path is contained inside the sandbox and false otherwise. +// This function does not check for the existence of the file, just checks if the +// path is contained in the sanbox root +func (sb *Sandbox) ContainsPath(path string) bool { + splitted := fileSplit(path) + for idx, comp := range fileSplit(sb.Root) { + if idx >= len(splitted) || comp != splitted[idx] { + return false + } + } + return true +} + +// Normalize returns the fully normalized version of path, including the Root prefix +func (sb *Sandbox) Normalize(path string) string { + if sb.ContainsPath(path) { + return path + } + return filepath.Join(sb.Root, path) +} diff --git a/internal/testutil/sandbox_test.go b/internal/testutil/sandbox_test.go new file mode 100644 index 0000000..56d6dba --- /dev/null +++ b/internal/testutil/sandbox_test.go @@ -0,0 +1,260 @@ +package testutil + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSandbox(t *testing.T) { + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb1 := NewSandbox(root) + defer sb1.Cleanup() + assert.Equal(t, root, sb1.Root) + + sb2 := NewSandbox() + defer sb2.Cleanup() + + assert.Regexp(t, + regexp.MustCompile(fmt.Sprintf(`^%s/+sandbox.*$`, filepath.Clean(os.TempDir()))), + sb2.Root) + + for _, s := range []*Sandbox{sb1, sb2} { + if !fileExists(s.Root) { + assert.Fail(t, "Expected %s to exists", s.Root) + } else { + s.Cleanup() + if fileExists(s.Root) { + assert.Fail(t, "Expected %s to not exist", s.Root) + } + } + } +} + +func TestCleanup(t *testing.T) { + sb := NewSandbox() + defer sb.Cleanup() + + tmpFile := filepath.Join(sb.Root, "sample.txt") + err := os.WriteFile(tmpFile, []byte{}, os.FileMode(0644)) + require.NoError(t, err) + + assert.NoError(t, sb.Cleanup()) + // Even if not created by the sandbox, we delete stuff if contained inside + assert.False(t, fileExists(tmpFile), "Expected %s to exists", tmpFile) + + sb.Track(tmpFile) + + assert.NoError(t, sb.Cleanup()) + sb.Mkdir(sb.Root, os.FileMode(0755)) + + assert.False(t, fileExists(tmpFile), "Expected %s to not exist", tmpFile) + + p1 := sb.Touch("foo.txt") + assert.True(t, fileExists(p1), "Expected %s to exists", p1) + + p2, err := sb.Mkdir("some/dir/bar", os.FileMode(0755)) + assert.NoError(t, err) + assert.True(t, fileExists(p2), "Expected %s to exists", p2) + + assert.NoError(t, sb.Cleanup()) + + assert.False(t, fileExists(sb.Root)) +} + +func TestNormalize(t *testing.T) { + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + for _, path := range []string{ + "/tmp/foo/bar", "/", "a.txt", "a/b/c/d", "", + } { + assert.Equal(t, sb.Normalize(path), filepath.Join(root, path)) + } +} + +func TestContainsPath(t *testing.T) { + + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + for path, isContained := range map[string]bool{ + "/tmp": false, + root: true, + "/": false, + // Relative paths are dangerous in this context, require full paths + "var/foo.txt": false, + "/var/foo.txt": false, + filepath.Join(root, "sample.txt"): true, + filepath.Join(root, "../sample.txt"): false, + "../sample.txt": false, + } { + assert.Equal(t, isContained, + sb.ContainsPath(path), + "Path %s fails is contained check in %s", path, root) + } +} + +func TestWriteFile(t *testing.T) { + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + data := "hello worlds!" + tail := "sample.txt" + f, err := sb.WriteFile(tail, []byte(data), os.FileMode(0644)) + assert.NoError(t, err) + assert.Equal(t, filepath.Join(root, tail), f) + read, err := os.ReadFile(f) + assert.NoError(t, err) + + assert.Equal(t, data, string(read)) +} + +func TestWrite(t *testing.T) { + + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + data := "hello worlds!" + tail := "sample.txt" + f, err := sb.Write(tail, data) + assert.NoError(t, err) + assert.Equal(t, filepath.Join(root, tail), f) + read, err := os.ReadFile(f) + assert.NoError(t, err) + + assert.Equal(t, data, string(read)) +} + +func TestSymlink(t *testing.T) { + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + f, err := sb.Symlink("../a", "b") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(root, "b"), f) + d, _ := os.Readlink(f) + assert.Equal(t, d, "../a") +} + +func TestMkdir(t *testing.T) { + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + tail := "sample_dir" + fullPath := filepath.Join(root, tail) + assert.False(t, fileExists(fullPath), "Expected %s to not exist", fullPath) + f, err := sb.Mkdir(tail, os.FileMode(0755)) + assert.NoError(t, err) + assert.Equal(t, fullPath, f) + require.True(t, fileExists(fullPath), "Expected %s to exists", fullPath) + + s, _ := os.Stat(fullPath) + assert.True(t, s.IsDir(), "Expected %s to be a directory", fullPath) +} +func TestTouch(t *testing.T) { + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + tail := "sample.txt" + fullPath := filepath.Join(root, tail) + assert.False(t, fileExists(fullPath), "Expected %s to not exist", fullPath) + assert.Equal(t, fullPath, sb.Touch(tail)) + require.True(t, fileExists(fullPath), "Expected %s to exists", fullPath) + + s1, _ := os.Stat(fullPath) + mt1 := s1.ModTime() + + // To avoid issues on OS X, we have to wait more then 1sec (HFS+ only stores timestamps to a granularity of one second) + // http://stackoverflow.com/questions/18403588/how-to-return-millisecond-information-for-file-access-on-mac-os-x-in-java/18404059#18404059 + time.Sleep(1500 * time.Millisecond) + sb.Touch(tail) + s2, _ := os.Stat(fullPath) + mt2 := s2.ModTime() + assert.NotEqual(t, mt1, mt2) +} + +func TestTempFile(t *testing.T) { + root, err := os.MkdirTemp("", "sandbox") + require.NoError(t, err) + + sb := NewSandbox(root) + defer sb.Cleanup() + + tail := "sample.txt" + f1 := sb.TempFile(tail) + f2 := sb.TempFile(tail) + assert.NotEqual(t, f1, f2) + for _, f := range []string{f1, f2} { + assert.False(t, fileExists(f)) + assert.Regexp(t, + regexp.MustCompile(fmt.Sprintf("^%s/%s[0-9]+$", root, tail)), + f) + } + + // tempFileIndex is index incremented each time a tmp file is requested + // If the file to create exists, an additional numeric index is appended until + // the target file does not exists + currentIndex := tempFileIndex + os.WriteFile( + filepath.Join(root, fmt.Sprintf("%s%d", tail, currentIndex)), + []byte{}, os.FileMode(0644), + ) + f3 := sb.TempFile(tail) + // If the file exists, it starts incrementing the index + assert.Equal(t, fmt.Sprintf("%s/%s%d0", root, tail, currentIndex), f3) + + currentIndex = tempFileIndex + os.WriteFile( + filepath.Join(root, fmt.Sprintf("%s%d", tail, currentIndex)), + []byte{}, os.FileMode(0644), + ) + for i := 0; i < 2; i++ { + os.WriteFile( + filepath.Join(root, fmt.Sprintf("%s%d%d", tail, currentIndex, i)), + []byte{}, os.FileMode(0644), + ) + } + f4 := sb.TempFile(tail) + // If the file exists, it starts incrementing the index + assert.Equal(t, fmt.Sprintf("%s/%s%d2", root, tail, currentIndex), f4) + + f5 := sb.TempFile() + f6 := sb.TempFile() + assert.NotEqual(t, f5, f6) + for _, f := range []string{f5, f6} { + assert.False(t, fileExists(f)) + assert.Regexp(t, + regexp.MustCompile(fmt.Sprintf("^%s/[0-9]+$", root)), + f) + } +} diff --git a/internal/testutil/server.go b/internal/testutil/server.go new file mode 100644 index 0000000..42b60b5 --- /dev/null +++ b/internal/testutil/server.go @@ -0,0 +1,153 @@ +// Package testutil implements functions used to test the different packages +package testutil + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/opencontainers/go-digest" +) + +// TestServer defines a images registry for testing +type TestServer struct { + ServerURL string + s *httptest.Server + responsesMap map[string]response +} + +// DigestData defines Digest information for an Architecture +type DigestData struct { + Arch string + Digest digest.Digest +} + +// ImageData defines information for a docker image +type ImageData struct { + Name string + Image string + Digests []DigestData +} + +// AddImage adds information for an image to the server so it can be later queried +func (s *TestServer) AddImage(img *ImageData) error { + imgID := img.Image + parts := strings.SplitN(imgID, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("failed to process image id: cannot find tag") + } + url := fmt.Sprintf("/v2/%s/manifests/%s", parts[0], parts[1]) + + s.responsesMap[url] = response{ + ContentType: "application/vnd.docker.distribution.manifest.list.v2+json", + Body: manifestResponse(img), + } + return nil +} + +// Close shuts down the test server +func (s *TestServer) Close() { + s.s.Close() +} + +// LoadImagesFromFile adds the images specified in the JSON file provided to the server +func (s *TestServer) LoadImagesFromFile(file string) ([]*ImageData, error) { + var allErrors error + var referenceImages []*ImageData + + fh, err := os.Open(file) + if err != nil { + return nil, err + } + defer fh.Close() + dec := json.NewDecoder(fh) + if err := dec.Decode(&referenceImages); err != nil { + return nil, fmt.Errorf("failed to decode reference images: %w", err) + } + + for _, img := range referenceImages { + if err := s.AddImage(img); err != nil { + allErrors = errors.Join(allErrors, err) + } + } + return referenceImages, allErrors +} + +// NewTestServer returns a new TestServer +func NewTestServer() (*TestServer, error) { + testServer := &TestServer{responsesMap: make(map[string]response)} + + s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "manifests") { + resp, ok := testServer.responsesMap[r.URL.Path] + if !ok { + w.WriteHeader(404) + _, err := w.Write([]byte(fmt.Sprintf("cannot find image %q", r.URL.Path))) + if err != nil { + log.Fatal(err) + } + return + } + w.Header().Set("Content-Type", resp.ContentType) + w.WriteHeader(200) + _, err := w.Write([]byte(resp.Body)) + if err != nil { + log.Fatal(err) + } + } else if r.URL.Path == "/v2/" { + w.WriteHeader(200) + } else { + w.WriteHeader(500) + } + })) + testServer.s = s + u, _ := url.Parse(s.URL) + testServer.ServerURL = fmt.Sprintf("localhost:%s", u.Port()) + return testServer, nil +} + +type response struct { + Body string + ContentType string +} + +func manifestResponse(img *ImageData) string { + tmpl, err := template.New("test").Funcs(fns).Funcs(sprig.FuncMap()).Parse(` + {{$listLen:= len .Digests}} + { + "schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json", + "manifests":[ +{{- range $i, $e := .Digests}} +{{- $archList := splitList "/" $e.Arch }} +{{- $os := index $archList 0 }} +{{- $arch := index $archList 1 }} + { + "mediaType":"application/vnd.docker.distribution.manifest.v2+json", + "size":430,"digest":"{{$e.Digest}}", + "platform":{"architecture":"{{$arch}}","os":"{{$os}}"} + }{{if not (isLast $i $listLen)}},{{end}} +{{- end}} + ] +}`) + if err != nil { + log.Fatal(err) + + } + b := &bytes.Buffer{} + + if err := tmpl.Execute(b, img); err != nil { + log.Fatal(err) + } + + _ = tmpl + return strings.TrimSpace(b.String()) +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..95a6839 --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,407 @@ +package testutil + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/opencontainers/go-digest" + "gopkg.in/yaml.v2" +) + +var ( + tmplExtension = ".tmpl" + partialExtension = ".partial" + tmplExtension +) + +var fns = template.FuncMap{ + "isLast": func(index int, len int) bool { + return index+1 == len + }, +} + +// RenderTemplateString renders a golang template defined in str with the provided tplData. +// It can receive an optional list of files to parse, including templates +func RenderTemplateString(str string, tplData interface{}, files ...string) (string, error) { + tmpl := template.New("test") + localFns := template.FuncMap{"include": func(name string, data interface{}) (string, error) { + buf := bytes.NewBuffer(nil) + if err := tmpl.ExecuteTemplate(buf, name, data); err != nil { + return "", err + } + return buf.String(), nil + }, + } + + tmpl, err := tmpl.Funcs(fns).Funcs(sprig.FuncMap()).Funcs(localFns).Parse(str) + if err != nil { + return "", err + } + if len(files) > 0 { + if _, err := tmpl.ParseFiles(files...); err != nil { + return "", err + } + } + b := &bytes.Buffer{} + + if err := tmpl.Execute(b, tplData); err != nil { + return "", err + } + return strings.TrimSpace(b.String()), nil +} + +// RenderTemplateFile renders the golang template specified in file with the provided tplData. +// It can receive an optional list of files to parse, including templates +func RenderTemplateFile(file string, tplData interface{}, files ...string) (string, error) { + data, err := os.ReadFile(file) + if err != nil { + return "", err + } + return RenderTemplateString(string(data), tplData, files...) +} + +// RenderScenario renders a full directory specified by origin in the destDir directory with +// the specified data +func RenderScenario(origin string, destDir string, data map[string]interface{}) error { + matches, err := filepath.Glob(origin) + if err != nil { + return err + } + if len(matches) == 0 { + return fmt.Errorf("cannot find any files at %q", origin) + } + templateFiles, err := filepath.Glob(filepath.Join(origin, fmt.Sprintf("*%s", partialExtension))) + _ = templateFiles + if err != nil { + return fmt.Errorf("faled to list template partials") + } + for _, p := range matches { + err := filepath.Walk(p, func(path string, info os.FileInfo, _ error) error { + if strings.HasSuffix(path, partialExtension) { + return nil + } + relative, _ := filepath.Rel(p, path) + destFile := filepath.Join(destDir, relative) + + if info.Mode().IsRegular() { + if strings.HasSuffix(path, tmplExtension) { + destFile = strings.TrimSuffix(destFile, tmplExtension) + rendered, err := RenderTemplateFile(path, data, templateFiles...) + if err != nil { + return fmt.Errorf("failed to render template %q: %v", path, err) + } + + if err := os.WriteFile(destFile, []byte(rendered), 0644); err != nil { + return err + } + } else { + err := copyFile(path, destFile) + if err != nil { + return fmt.Errorf("failed to copy %q: %v", path, err) + } + } + } else if info.IsDir() { + if err := os.MkdirAll(destFile, info.Mode()); err != nil { + return fmt.Errorf("failed to create directory: %v", err) + } + } else { + return fmt.Errorf("unknown file type (%s)", path) + } + if err := os.Chmod(destFile, info.Mode().Perm()); err != nil { + log.Printf("DEBUG: failed to change file %q permissions: %v", destFile, err) + } + return nil + }) + if err != nil { + return err + } + } + return nil +} + +type sampleImageData struct { + Index v1.ImageIndex + ImageData ImageData +} + +func createSampleImages(imageName string, server string) (map[string]sampleImageData, error) { + images := make(map[string]sampleImageData, 0) + src := fmt.Sprintf("%s/%s", server, imageName) + imageData := ImageData{Name: "test", Image: imageName} + base := mutate.IndexMediaType(empty.Index, types.DockerManifestList) + + addendums := []mutate.IndexAddendum{} + + for _, plat := range []string{ + "linux/amd64", + "linux/arm64", + } { + img, err := crane.Image(map[string][]byte{ + "platform.txt": []byte(fmt.Sprintf("Image: %s ; plaform: %s", imageName, plat)), + }) + if err != nil { + return nil, fmt.Errorf("failed to create image: %v", err) + } + parts := strings.Split(plat, "/") + + img, err = mutate.ConfigFile(img, &v1.ConfigFile{Architecture: parts[1], OS: parts[0]}) + if err != nil { + return nil, fmt.Errorf("cannot mutatle image config file: %w", err) + } + + img, err = mutate.Canonical(img) + + if err != nil { + return nil, fmt.Errorf("failed to canonicalize image: %w", err) + } + + addendums = append(addendums, mutate.IndexAddendum{ + Add: img, + Descriptor: v1.Descriptor{ + Platform: &v1.Platform{ + OS: parts[0], + Architecture: parts[1], + }, + }, + }) + d, err := img.Digest() + if err != nil { + return nil, fmt.Errorf("failed to generate digest: %v", err) + } + imageData.Digests = append(imageData.Digests, DigestData{Arch: plat, Digest: digest.Digest(d.String())}) + } + + idx := mutate.AppendManifests(base, addendums...) + + images[src] = sampleImageData{Index: idx, ImageData: imageData} + return images, nil +} + +// Auth defines the authentication information to access the container registry +type Auth struct { + Username string + Password string +} + +// Config defines multiple test util options +type Config struct { + SignKey string + MetadataDir string + Auth Auth +} + +// NewConfig returns a new Config +func NewConfig(opts ...Option) *Config { + cfg := &Config{} + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// Option defines a Config option +type Option func(*Config) + +// WithSignKey sets a signing key to be used while pushing images +func WithSignKey(key string) Option { + return func(cfg *Config) { + cfg.SignKey = key + } +} + +// WithMetadataDir sets a signing key to be used while pushing images +func WithMetadataDir(dir string) Option { + return func(cfg *Config) { + cfg.MetadataDir = dir + } +} + +// WithAuth sets the credentials to access the container registry +func WithAuth(username, password string) Option { + return func(cfg *Config) { + cfg.Auth.Username = username + cfg.Auth.Password = password + } +} + +// AddSampleImagesToRegistry adds a set of sample images to the provided registry +func AddSampleImagesToRegistry(imageName string, server string, opts ...Option) ([]ImageData, error) { + cfg := NewConfig(opts...) + images := make([]ImageData, 0) + samples, err := createSampleImages(imageName, server) + if err != nil { + return nil, err + } + authenticator := authn.Anonymous + if cfg.Auth.Username != "" && cfg.Auth.Password != "" { + authenticator = &authn.Basic{Username: cfg.Auth.Username, Password: cfg.Auth.Password} + } + + for src, data := range samples { + ref, err := name.ParseReference(src) + if err != nil { + return nil, fmt.Errorf("failed to parse reference: %v", err) + } + if err := remote.WriteIndex(ref, data.Index, remote.WithAuth(authenticator)); err != nil { + return nil, fmt.Errorf("failed to write index: %v", err) + } + images = append(images, data.ImageData) + if cfg.SignKey != "" { + if err := CosignImage(src, cfg.SignKey, crane.WithAuth(authenticator)); err != nil { + return nil, fmt.Errorf("failed to sign image %q: %v", src, err) + } + } + if cfg.MetadataDir != "" { + newDir := fmt.Sprintf("%s.layout", cfg.MetadataDir) + if err := CreateOCILayout(context.Background(), cfg.MetadataDir, newDir); err != nil { + return nil, fmt.Errorf("failed to serialize metadata as OCI layout: %v", err) + } + imgDigest, err := data.Index.Digest() + if err != nil { + return nil, fmt.Errorf("failed to get image digest: %v", err) + } + metadataImg := fmt.Sprintf("%s:sha256-%s.metadata", ref.Context().Name(), imgDigest.Hex) + + if err := pushArtifact(context.Background(), metadataImg, newDir, crane.WithAuth(authenticator)); err != nil { + return nil, fmt.Errorf("failed to push metadata: %v", err) + } + if cfg.SignKey != "" { + if err := CosignImage(metadataImg, cfg.SignKey, crane.WithAuth(authenticator)); err != nil { + return nil, fmt.Errorf("failed to sign image %q: %v", src, err) + } + } + + } + } + return images, nil +} + +// CreateSingleArchImage creates a sample image for the specified platform +func CreateSingleArchImage(imageData *ImageData, plat string) (v1.Image, error) { + imageName := imageData.Image + + img, err := crane.Image(map[string][]byte{ + "platform.txt": []byte(fmt.Sprintf("Image: %s ; plaform: %s", imageName, plat)), + }) + if err != nil { + return nil, fmt.Errorf("failed to create image: %w", err) + } + parts := strings.Split(plat, "/") + img, err = mutate.ConfigFile(img, &v1.ConfigFile{Architecture: parts[1], OS: parts[0]}) + if err != nil { + return nil, fmt.Errorf("cannot mutatle image config file: %w", err) + } + + img, err = mutate.Canonical(img) + if err != nil { + return nil, fmt.Errorf("failed to canonicalize image: %w", err) + } + d, err := img.Digest() + if err != nil { + return nil, fmt.Errorf("failed to get image digest: %w", err) + } + imageData.Digests = append(imageData.Digests, DigestData{Arch: plat, Digest: digest.Digest(d.String())}) + + return img, nil +} + +// CreateSampleImages create a multiplatform sample image +func CreateSampleImages(imageData *ImageData, archs []string) ([]v1.Image, error) { + craneImgs := []v1.Image{} + + for _, plat := range archs { + img, err := CreateSingleArchImage(imageData, plat) + if err != nil { + return nil, err + } + craneImgs = append(craneImgs, img) + } + return craneImgs, nil +} + +// ReadRemoteImageManifest reads the image src digests from a remote repository +func ReadRemoteImageManifest(src string, opts ...Option) (map[string]DigestData, error) { + cfg := NewConfig(opts...) + authenticator := authn.Anonymous + if cfg.Auth.Username != "" && cfg.Auth.Password != "" { + authenticator = &authn.Basic{Username: "username", Password: "password"} + } + o := crane.GetOptions(crane.WithAuth(authenticator)) + + ref, err := name.ParseReference(src, o.Name...) + + if err != nil { + return nil, fmt.Errorf("failed to parse reference %q: %w", src, err) + } + desc, err := remote.Get(ref, o.Remote...) + if err != nil { + return nil, fmt.Errorf("failed to get remote image: %w", err) + } + + var idx v1.IndexManifest + if err := json.Unmarshal(desc.Manifest, &idx); err != nil { + return nil, fmt.Errorf("failed to parse images data") + } + digests := make(map[string]DigestData, 0) + + var allErrors error + for _, img := range idx.Manifests { + // Skip attestations + if img.Annotations["vnd.docker.reference.type"] == "attestation-manifest" { + continue + } + switch img.MediaType { + case types.OCIManifestSchema1, types.DockerManifestSchema2: + if img.Platform == nil { + continue + } + + arch := fmt.Sprintf("%s/%s", img.Platform.OS, img.Platform.Architecture) + imgDigest := DigestData{ + Digest: digest.Digest(img.Digest.String()), + Arch: arch, + } + digests[arch] = imgDigest + default: + allErrors = errors.Join(allErrors, fmt.Errorf("unknown media type %q", img.MediaType)) + continue + } + } + return digests, allErrors +} + +// MustNormalizeYAML returns the normalized version of the text YAML or panics +func MustNormalizeYAML(text string) string { + t, err := NormalizeYAML(text) + if err != nil { + panic(err) + } + return t +} + +// NormalizeYAML returns a normalized version of the provided YAML text +func NormalizeYAML(text string) (string, error) { + var out interface{} + err := yaml.Unmarshal([]byte(text), &out) + if err != nil { + return "", err + } + data, err := yaml.Marshal(out) + return string(data), err +} diff --git a/internal/testutil/utils.go b/internal/testutil/utils.go new file mode 100644 index 0000000..af97316 --- /dev/null +++ b/internal/testutil/utils.go @@ -0,0 +1,40 @@ +package testutil + +import ( + "io" + "os" + "path/filepath" + "strings" +) + +func fileSplit(p string) []string { + return strings.Split(filepath.Clean(p), "/") +} + +func fileExists(f string) bool { + if _, err := os.Stat(f); err == nil { + return true + } + return false +} + +func copyFile(srcFile string, destFile string) error { + src, err := os.Open(srcFile) + if err != nil { + return err + } + defer src.Close() + + dest, err := os.Create(destFile) + if err != nil { + return err + } + defer dest.Close() + + _, err = io.Copy(dest, src) + if err != nil { + return err + } + + return dest.Sync() +} diff --git a/internal/widgets/constants.go b/internal/widgets/constants.go new file mode 100644 index 0000000..f86603f --- /dev/null +++ b/internal/widgets/constants.go @@ -0,0 +1,7 @@ +package widgets + +const ( + // TerminalSpacer is a text to print to terminal to separate sections to improve readability + // An empty string will just add a new line + TerminalSpacer = "" +) diff --git a/internal/widgets/interactive.go b/internal/widgets/interactive.go new file mode 100644 index 0000000..21d2271 --- /dev/null +++ b/internal/widgets/interactive.go @@ -0,0 +1,12 @@ +// Package widgets provides a set of reusable widgets for the distribution-tooling-for-helm CLI +package widgets + +import ( + "github.com/pterm/pterm" +) + +// ShowYesNoQuestion shows the yes/no question message provided +func ShowYesNoQuestion(question string) bool { + result, _ := pterm.DefaultInteractiveConfirm.Show(question) + return result +} diff --git a/internal/widgets/spinner.go b/internal/widgets/spinner.go new file mode 100644 index 0000000..68fcc60 --- /dev/null +++ b/internal/widgets/spinner.go @@ -0,0 +1,45 @@ +package widgets + +import ( + "github.com/pterm/pterm" + "github.com/pterm/pterm/putils" +) + +var ( + // DefaultSpinner defines the default spinner widget + DefaultSpinner Spinner +) + +func prefixSequence(prefix string, sequence ...string) []string { + newSequence := make([]string, len(sequence)) + for i, str := range sequence { + newSequence[i] = prefix + str + } + return newSequence +} + +// Spinner defines a widget that shows a indeterminate progress animation +type Spinner struct { + *pterm.SpinnerPrinter +} + +// WithPrefix returns a new Spinner the with the specified prefix +func (s *Spinner) WithPrefix(prefix string) *Spinner { + return &Spinner{s.WithSequence(prefixSequence(prefix, s.Sequence...)...)} +} + +func init() { + DefaultSpinner = Spinner{pterm.DefaultSpinner.WithSequence(prefixSequence(" ", "โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ ")...)} +} + +// ExecuteWithSpinner runs the provided function while executing spinner +func ExecuteWithSpinner(spinner *Spinner, message string, fn func() error) error { + return putils.RunWithSpinner(spinner.WithRemoveWhenDone(true).WithText(message), func(_ *pterm.SpinnerPrinter) error { + return fn() + }) +} + +// ExecuteWithDefaultSpinner runs the provided function while executing the default spinner +func ExecuteWithDefaultSpinner(message string, fn func() error) error { + return ExecuteWithSpinner(&DefaultSpinner, message, fn) +} diff --git a/pkg/artifacts/artifacts.go b/pkg/artifacts/artifacts.go new file mode 100644 index 0000000..8d0df07 --- /dev/null +++ b/pkg/artifacts/artifacts.go @@ -0,0 +1,422 @@ +// Package artifacts implements support to pushing and pulling artifacts to an OCI registry +package artifacts + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/opencontainers/go-digest" + "golang.org/x/exp/slices" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +const ( + // HelmArtifactsFolder defines the path of the chart artifacts, relative to the bundle root + HelmArtifactsFolder = "artifacts" + + // HelmChartArtifactMetadataDir defines the relative path to the chart metadata inside the chart root + HelmChartArtifactMetadataDir = HelmArtifactsFolder + "/chart/metadata" +) + +var ( + // ErrTagDoesNotExist defines an error locating a remote tag because it does not exist + ErrTagDoesNotExist = errors.New("tag does not exist") + // ErrLocalArtifactNotExist defines an error locating a local artifact because it does not exist + ErrLocalArtifactNotExist = errors.New("local artifact does not exist") +) + +// Auth defines the authentication information to access the container registry +type Auth struct { + Username string + Password string +} + +// Config defines the configuration when pulling/pushing artifacts to a registry +type Config struct { + ResolveReference bool + InsecureMode bool + Auth Auth +} + +// Option defines a Config option +type Option func(*Config) + +// WithAuth configures the Auth +func WithAuth(username, password string) func(cfg *Config) { + return func(cfg *Config) { + cfg.Auth = Auth{ + Username: username, + Password: password, + } + } +} + +// WithInsecureMode configures Insecure transport +func WithInsecureMode(insecure bool) func(cfg *Config) { + return func(cfg *Config) { + cfg.InsecureMode = insecure + } +} + +// WithResolveReference configures the ResolveReference setting +func WithResolveReference(v bool) func(cfg *Config) { + return func(cfg *Config) { + cfg.ResolveReference = v + } +} + +// NewConfig creates a new Config +func NewConfig(opts ...Option) *Config { + cfg := &Config{ResolveReference: true, InsecureMode: false} + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +func getImageTagAndDigest(image string, opts ...Option) (string, string, error) { + ref, err := name.ParseReference(image) + if err != nil { + return "", "", fmt.Errorf("failed to parse image reference: %w", err) + } + + var hex string + var imgTag string + + switch v := ref.(type) { + case name.Tag: + cfg := NewConfig(opts...) + craneOpts := make([]crane.Option, 0) + if cfg.InsecureMode { + craneOpts = append(craneOpts, crane.Insecure) + } + if cfg.Auth.Password != "" && cfg.Auth.Username != "" { + craneOpts = append(craneOpts, crane.WithAuth(&authn.Basic{ + Username: cfg.Auth.Username, + Password: cfg.Auth.Password, + })) + } + desc, err := imagelock.GetImageRemoteDescriptor(image, craneOpts...) + if err != nil { + return "", "", fmt.Errorf("error getting descriptor: %w", err) + } + hex = desc.Digest.Hex + imgTag = v.TagStr() + case name.Digest: + digestStr := v.DigestStr() + prefix := digest.Canonical.String() + ":" + if !strings.HasPrefix(digestStr, prefix) { + return "", "", fmt.Errorf("unsupported digest algorithm: %s", digestStr) + } + hex = strings.TrimPrefix(digestStr, prefix) + imgTag = strings.TrimPrefix(digestStr, prefix) + default: + return "", "", fmt.Errorf("unsupported reference type %T", v) + } + return imgTag, hex, nil +} + +func getImageArtifactsDir(image *imagelock.ChartImage, destDir string, suffix string, opts ...Option) (string, error) { + imgTag, _, err := getImageTagAndDigest(image.Image, opts...) + if err != nil { + return "", fmt.Errorf("failed to parse image reference: %w", err) + } + + return filepath.Join(destDir, image.Chart, image.Name, fmt.Sprintf("%s.%s", imgTag, suffix)), nil +} + +func pushArtifact(ctx context.Context, image string, dest string, tagSuffix string, opts ...Option) (string, error) { + cfg := NewConfig(opts...) + if !utils.FileExists(dest) { + return "", ErrLocalArtifactNotExist + } + craneOpts := []crane.Option{crane.WithContext(ctx)} + + if cfg.Auth.Password != "" && cfg.Auth.Username != "" { + craneOpts = append(craneOpts, crane.WithAuth(&authn.Basic{ + Username: cfg.Auth.Username, + Password: cfg.Auth.Password, + })) + } + repo, err := getImageRepository(image) + if err != nil { + return "", fmt.Errorf("failed to get image repository: %w", err) + } + + imgTag, hex, err := getImageTagAndDigest(image, opts...) + if err != nil { + return "", err + } + + var tag string + if cfg.ResolveReference { + tag = fmt.Sprintf("sha256-%s.%s", hex, tagSuffix) + } else { + tag = fmt.Sprintf("%s-%s", imgTag, tagSuffix) + } + img, err := loadImage(dest) + if err != nil { + return "", err + } + + newImg := fmt.Sprintf("%s:%s", repo, tag) + + switch t := img.(type) { + case v1.Image: + return tag, crane.Push(t, newImg, craneOpts...) + default: + return "", fmt.Errorf("unsupported image type %T", t) + } +} + +func pushAssetMetadata(ctx context.Context, imageRef string, destDir string, opts ...Option) error { + tag, err := pushArtifact(ctx, imageRef, destDir, "metadata", opts...) + if err != nil { + return err + } + repo, err := getImageRepository(imageRef) + if err != nil { + return fmt.Errorf("failed to get image repository: %w", err) + } + metadataImg := fmt.Sprintf("%s:%s", repo, tag) + + metadataSigDir := fmt.Sprintf("%s.sig", destDir) + // For the metadata pull, we may want to not resolve the tag to the shasum, but for the signature, we need to do it, + // so we enfoce it here + _, err = pushArtifact(ctx, metadataImg, metadataSigDir, "sig", append(opts, WithResolveReference(true))...) + if err != nil { + return err + } + return nil +} + +// PushImageMetadata pushes a oci-layout directory to the registry as the image metadata +func PushImageMetadata(ctx context.Context, image *imagelock.ChartImage, destDir string, opts ...Option) error { + imageRef := image.Image + + dir, err := getImageArtifactsDir(image, destDir, "metadata", opts...) + if err != nil { + return fmt.Errorf("failed to obtain metadata location: %v", err) + } + + return pushAssetMetadata(ctx, imageRef, dir, opts...) +} + +// PushImageSignatures pushes a oci-layout directory to the registry as the image signature +func PushImageSignatures(ctx context.Context, image *imagelock.ChartImage, destDir string, opts ...Option) error { + imageRef := image.Image + dir, err := getImageArtifactsDir(image, destDir, "sig", opts...) + if err != nil { + return fmt.Errorf("failed to obtain signature location: %v", err) + } + _, err = pushArtifact(ctx, imageRef, dir, "sig", opts...) + if err != nil { + return err + } + return nil +} + +func getImageRepository(image string) (string, error) { + ref, err := name.ParseReference(image) + if err != nil { + return "", fmt.Errorf("failed to parse image reference: %w", err) + } + return ref.Context().Name(), nil +} + +func pullArtifact(ctx context.Context, image string, destDir string, tagSuffix string, opts ...Option) (string, error) { + cfg := NewConfig(opts...) + + craneOpts := []crane.Option{crane.WithContext(ctx)} + if cfg.InsecureMode { + craneOpts = append(craneOpts, crane.Insecure) + } + if cfg.Auth.Password != "" && cfg.Auth.Username != "" { + craneOpts = append(craneOpts, crane.WithAuth(&authn.Basic{ + Username: cfg.Auth.Username, + Password: cfg.Auth.Password, + })) + } + o := crane.GetOptions(craneOpts...) + + repo, err := getImageRepository(image) + if err != nil { + return "", fmt.Errorf("failed to get image repository: %w", err) + } + + var tag string + imgTag, hex, err := getImageTagAndDigest(image, opts...) + if err != nil { + return "", err + } + + if cfg.ResolveReference { + tag = fmt.Sprintf("sha256-%s.%s", hex, tagSuffix) + } else { + tag = fmt.Sprintf("%s-%s", imgTag, tagSuffix) + } + + exist, err := TagExist(ctx, repo, tag, o) + if err != nil { + return "", fmt.Errorf("failed to check tag %q: %w", tag, err) + } + if !exist { + return "", ErrTagDoesNotExist + } + + newImg := fmt.Sprintf("%s:%s", repo, tag) + rmt, err := imagelock.GetImageRemoteDescriptor(newImg, craneOpts...) + if err != nil { + return "", err + } + img, err := rmt.Image() + if err != nil { + return "", err + } + if err := saveImage(img, destDir); err != nil { + return "", err + } + return tag, nil +} + +// PullImageMetadata pulls the image metadata and stores it locally as an oci-layout +func PullImageMetadata(ctx context.Context, image *imagelock.ChartImage, destDir string, opts ...Option) error { + imageRef := image.Image + + dir, err := getImageArtifactsDir(image, destDir, "metadata", opts...) + if err != nil { + return fmt.Errorf("failed to obtain metadata location: %v", err) + } + + return pullAssetMetadata(ctx, imageRef, dir, opts...) +} + +func pullAssetMetadata(ctx context.Context, imageRef string, dir string, opts ...Option) error { + tag, err := pullArtifact(ctx, imageRef, dir, "metadata", opts...) + if err != nil { + return err + } + repo, err := getImageRepository(imageRef) + if err != nil { + return fmt.Errorf("failed to get image repository: %w", err) + } + metadataImg := fmt.Sprintf("%s:%s", repo, tag) + + // For the metadata pull, we may want to not resolve the tag to the shasum, but for the signature, we need to do it, + // so we enfoce it here + metadataSigDir := fmt.Sprintf("%s.sig", dir) + _, err = pullArtifact(ctx, metadataImg, metadataSigDir, "sig", append(opts, WithResolveReference(true))...) + if err != nil { + return err + } + return nil +} + +// PullImageSignatures pulls the image signature and stores it locally as an oci-layout +func PullImageSignatures(ctx context.Context, image *imagelock.ChartImage, destDir string, opts ...Option) error { + imageRef := image.Image + dir, err := getImageArtifactsDir(image, destDir, "sig", opts...) + if err != nil { + return fmt.Errorf("failed to obtain signature location: %v", err) + } + _, err = pullArtifact(ctx, imageRef, dir, "sig", opts...) + if err != nil { + return err + } + return nil +} + +// TagExist checks if a given tag exist in the provided repository +func TagExist(ctx context.Context, src string, tag string, o crane.Options) (bool, error) { + result, err := listTags(ctx, src, o) + if err != nil { + return false, err + } + return slices.Contains(result, tag), nil +} + +// ListTags lists the defined tags in the repository +func ListTags(ctx context.Context, src string, opts ...crane.Option) ([]string, error) { + o := crane.GetOptions(opts...) + return listTags(ctx, src, o) +} + +func listTags(ctx context.Context, src string, o crane.Options) ([]string, error) { + result := make([]string, 0) + repo, err := name.NewRepository(src, o.Name...) + if err != nil { + return nil, fmt.Errorf("parsing repo %q: %w", src, err) + } + + puller, err := remote.NewPuller(o.Remote...) + if err != nil { + return nil, err + } + + lister, err := puller.Lister(ctx, repo) + if err != nil { + return nil, fmt.Errorf("reading tags for %s: %w", repo, err) + } + + for lister.HasNext() { + tags, err := lister.Next(ctx) + if err != nil { + return result, err + } + result = append(result, tags.Tags...) + } + return result, nil +} + +func saveImage(img v1.Image, dir string) error { + if err := crane.SaveOCI(img, dir); err != nil { + return fmt.Errorf("failed to save image: %v", err) + } + return nil +} + +func loadImage(path string) (partial.WithRawManifest, error) { + stat, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !stat.IsDir() { + return nil, fmt.Errorf("expected %q to be a directory", path) + } + + l, err := layout.ImageIndexFromPath(path) + if err != nil { + return nil, fmt.Errorf("loading %s as OCI layout: %w", path, err) + } + + m, err := l.IndexManifest() + if err != nil { + return nil, err + } + if len(m.Manifests) != 1 { + return nil, fmt.Errorf("layout contains multiple entries (%d)", len(m.Manifests)) + } + + desc := m.Manifests[0] + if desc.MediaType.IsImage() { + return l.Image(desc.Digest) + } else if desc.MediaType.IsIndex() { + return l.ImageIndex(desc.Digest) + } + return nil, fmt.Errorf("layout contains non-image (mediaType: %q)", desc.MediaType) +} diff --git a/pkg/artifacts/helm.go b/pkg/artifacts/helm.go new file mode 100644 index 0000000..666c467 --- /dev/null +++ b/pkg/artifacts/helm.go @@ -0,0 +1,236 @@ +package artifacts + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/containerd/containerd/remotes/docker" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/registry" +) + +// RegistryClientConfig defines how the client communicates with the remote server +type RegistryClientConfig struct { + UsePlainHTTP bool + UseInsecureHTTPS bool + Auth Auth + TempDir string +} + +// RegistryClientOption defines a RegistryClientConfig setting +type RegistryClientOption func(*RegistryClientConfig) + +type registryClientWrap struct { + client *registry.Client + credentialsFile string +} + +// WithRegistryAuth configures the Auth of the RegistryClientConfig +func WithRegistryAuth(username, password string) func(c *RegistryClientConfig) { + return func(c *RegistryClientConfig) { + c.Auth = Auth{Username: username, Password: password} + } +} + +// Insecure asks the tool to allow insecure HTTPS connections to the remote server. +func Insecure(c *RegistryClientConfig) { + c.UseInsecureHTTPS = true +} + +// WithInsecure configures the InsecureMode of the Config +func WithInsecure(insecure bool) func(c *RegistryClientConfig) { + return func(c *RegistryClientConfig) { + c.UseInsecureHTTPS = insecure + } +} + +// WithPlainHTTP configures the InsecureMode of the Config +func WithPlainHTTP(usePlain bool) func(c *RegistryClientConfig) { + return func(c *RegistryClientConfig) { + c.UsePlainHTTP = usePlain + } +} + +// WithTempDir configures the directory in which to place the temporary credentials file +func WithTempDir(dir string) func(c *RegistryClientConfig) { + return func(c *RegistryClientConfig) { + c.TempDir = dir + } +} + +// NewRegistryClientConfig returns a new RegistryClientConfig with default values +func NewRegistryClientConfig(opts ...RegistryClientOption) *RegistryClientConfig { + cfg := &RegistryClientConfig{ + UsePlainHTTP: false, + UseInsecureHTTPS: false, + } + + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +func getRegistryClientWrap(cfg *RegistryClientConfig) (*registryClientWrap, error) { + wrap := ®istryClientWrap{} + opts := []registry.ClientOption{} + if cfg.UsePlainHTTP { + opts = append(opts, registry.ClientOptPlainHTTP()) + } else { + if cfg.UseInsecureHTTPS { // #nosec G402 + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + opts = append(opts, registry.ClientOptHTTPClient(httpClient)) + } + } + if cfg.Auth.Username != "" && cfg.Auth.Password != "" { + f, err := os.CreateTemp(cfg.TempDir, "dt-config-*.json") + if err != nil { + return nil, fmt.Errorf("error creating credentials file: %w", err) + } + wrap.credentialsFile = f.Name() + err = f.Close() + if err != nil { + return nil, fmt.Errorf("error closing credentials file: %w", err) + } + opts = append(opts, registry.ClientOptCredentialsFile(f.Name())) + revOpts := docker.ResolverOptions{} + authz := docker.NewDockerAuthorizer(docker.WithAuthCreds(func(_ string) (string, string, error) { + return cfg.Auth.Username, cfg.Auth.Password, nil + })) + revOpts.Hosts = docker.ConfigureDefaultRegistries( + docker.WithAuthorizer(authz), + docker.WithPlainHTTP(func(_ string) (bool, error) { return cfg.UsePlainHTTP, nil }), + ) + rev := docker.NewResolver(revOpts) + + opts = append(opts, registry.ClientOptResolver(rev)) + } + r, err := registry.NewClient(opts...) + if err != nil { + return nil, err + } + wrap.client = r + + return wrap, nil + +} + +// PullChart retrieves the specified chart +func PullChart(chartURL, version string, destDir string, opts ...RegistryClientOption) (string, error) { + u, err := url.Parse(chartURL) + if err != nil { + return "", fmt.Errorf("invalid url: %w", err) + } + cfg := &action.Configuration{} + cc := NewRegistryClientConfig(opts...) + reg, err := getRegistryClientWrap(cc) + if err != nil { + return "", fmt.Errorf("missing registry client: %w", err) + } + cfg.RegistryClient = reg.client + if cc.Auth.Username != "" && cc.Auth.Password != "" && reg.credentialsFile != "" { + if err := reg.client.Login(u.Host, registry.LoginOptBasicAuth(cc.Auth.Username, cc.Auth.Password)); err != nil { + return "", fmt.Errorf("error logging in to %s: %w", u.Host, err) + } + defer func() { + _ = reg.client.Logout(u.Host) + _ = os.Remove(reg.credentialsFile) + }() + } + client := action.NewPullWithOpts(action.WithConfig(cfg)) + + dir, err := os.MkdirTemp(destDir, "chart-*") + if err != nil { + return "", fmt.Errorf("failed to upload Helm chart: failed to create temp directory: %w", err) + } + client.Settings = cli.New() + client.DestDir = dir + client.Untar = true + client.Version = version + _, err = client.Run(chartURL) + if err != nil { + return "", fmt.Errorf("failed to pull Helm chart: %w", err) + } + + charts, err := filepath.Glob(filepath.Join(dir, "*/Chart.yaml")) + if err != nil { + return "", fmt.Errorf("failed to located fetched Helm charts: %w", err) + } + if len(charts) == 0 { + return "", fmt.Errorf("cannot find any Helm chart") + } + if len(charts) > 1 { + return "", fmt.Errorf("found multiple Helm charts") + } + return filepath.Dir(charts[0]), nil +} + +// PushChart pushes the local chart tarFile to the remote URL provided +func PushChart(tarFile string, pushChartURL string, opts ...RegistryClientOption) error { + cfg := &action.Configuration{} + reg, err := getRegistryClientWrap(NewRegistryClientConfig(opts...)) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + cfg.RegistryClient = reg.client + client := action.NewPushWithOpts(action.WithPushConfig(cfg)) + + client.Settings = cli.New() + + if _, err := client.Run(tarFile, pushChartURL); err != nil { + return fmt.Errorf("failed to push Helm chart: %w", err) + } + + return nil +} + +func showRemoteHelmChart(chartURL string, version string, cfg *RegistryClientConfig) (string, error) { + client := action.NewShowWithConfig(action.ShowChart, &action.Configuration{}) + reg, err := getRegistryClientWrap(cfg) + if err != nil { + return "", fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(reg.client) + client.Version = version + cp, err := client.ChartPathOptions.LocateChart(chartURL, cli.New()) + + if err != nil { + return "", err + } + return client.Run(cp) +} + +// RemoteChartExist checks if the provided chart exists +func RemoteChartExist(chartURL string, version string, opts ...RegistryClientOption) bool { + _, err := showRemoteHelmChart(chartURL, version, NewRegistryClientConfig(opts...)) + return err == nil +} + +// FetchChartMetadata retrieves the chart metadata artifact from the registry +func FetchChartMetadata(ctx context.Context, url string, destination string, opts ...Option) error { + reference := strings.TrimPrefix(url, "oci://") + allOpts := append(opts, WithResolveReference(false)) + return pullAssetMetadata(ctx, reference, destination, allOpts...) +} + +// PushChartMetadata pushes the chart metadata artifact to the registry +func PushChartMetadata(ctx context.Context, url string, chartDir string, opts ...Option) error { + reference := strings.TrimPrefix(url, "oci://") + allOpts := append(opts, WithResolveReference(false)) + + return pushAssetMetadata(ctx, reference, chartDir, allOpts...) +} diff --git a/pkg/carvel/carvel.go b/pkg/carvel/carvel.go new file mode 100644 index 0000000..2f00dd0 --- /dev/null +++ b/pkg/carvel/carvel.go @@ -0,0 +1,156 @@ +// Package carvel implements experimental Carvel support +package carvel + +import ( + "fmt" + "io" + "strings" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-tanzu/carvel-imgpkg/pkg/imgpkg/lockconfig" + + "gopkg.in/yaml.v3" +) + +// CarvelBundleFilePath represents the usual bundle file for Carvel packaging +const CarvelBundleFilePath = ".imgpkg/bundle.yml" + +// CarvelImagesFilePath represents the usual images file for Carvel packaging +const CarvelImagesFilePath = ".imgpkg/images.yml" + +const carvelID = "kbld.carvel.dev/id" + +// Somehow there is no data structure for a bundle in Carvel. Copying some basics from the describe command. + +// Author information from a Bundle +type Author struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +// Website URL where more information of the Bundle can be found +type Website struct { + URL string `json:"url,omitempty"` +} + +// Bundle Metadata +const ( + BundleAPIVersion = "imgpkg.carvel.dev/v1alpha1" + BundleKind = "Bundle" +) + +// BundleVersion with detailsa bout the Carvel bundle version +type BundleVersion struct { + APIVersion string `json:"apiVersion"` // This generated yaml, but due to lib we need to use `json` + Kind string `json:"kind"` // This generated yaml, but due to lib we need to use `json` +} + +// Metadata for a Carvel bundle +type Metadata struct { + Version BundleVersion + Metadata map[string]string `json:"metadata,omitempty"` + Authors []Author `json:"authors,omitempty"` + Websites []Website `json:"websites,omitempty"` +} + +// ToYAML serializes the Carvel bundle into YAML +func (il *Metadata) ToYAML(w io.Writer) error { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + + return enc.Encode(il) +} + +// NewCarvelBundle returns a new carvel bundle Metadata instance +func NewCarvelBundle() *Metadata { + return &Metadata{ + Version: BundleVersion{ + APIVersion: BundleAPIVersion, + Kind: BundleKind, + }, + Metadata: map[string]string{}, + Authors: []Author{}, + Websites: []Website{}, + } +} + +// CreateBundleMetadata builds and sets a new Carvel bundle struct +func CreateBundleMetadata(chartPath string, lock *imagelock.ImagesLock, cfg *chartutils.Configuration) (*Metadata, error) { + bundleMetadata := NewCarvelBundle() + + chart, err := chartutils.LoadChart(chartPath) + if err != nil { + return nil, fmt.Errorf("failed to load chart: %w", err) + } + + for _, maintainer := range chart.Metadata().Maintainers { + author := Author{ + Name: maintainer.Name, + } + author.Email = maintainer.Email + bundleMetadata.Authors = append(bundleMetadata.Authors, author) + } + for _, source := range chart.Metadata().Sources { + website := Website{ + URL: source, + } + bundleMetadata.Websites = append(bundleMetadata.Websites, website) + } + + bundleMetadata.Metadata["name"] = lock.Chart.Name + for key, value := range chart.Metadata().Annotations { + annotationsKey := cfg.AnnotationsKey + if annotationsKey == "" { + annotationsKey = imagelock.DefaultAnnotationsKey + } + if key != annotationsKey { + bundleMetadata.Metadata[key] = value + } + } + return bundleMetadata, nil +} + +// CreateImagesLock builds and set a new Carvel images lock struct +func CreateImagesLock(lock *imagelock.ImagesLock) (lockconfig.ImagesLock, error) { + imagesLock := lockconfig.ImagesLock{ + LockVersion: lockconfig.LockVersion{ + APIVersion: lockconfig.ImagesLockAPIVersion, + Kind: lockconfig.ImagesLockKind, + }, + } + for _, img := range lock.Images { + // Carvel does not seem to support multi-arch. Grab amd64 digest + + name := img.Image + i := strings.LastIndex(img.Image, ":") + if i > -1 { + name = img.Image[0:i] + } + //TODO: Clarify with Carvel community their multi-arch support + //for the time being we stick to amd64 + imageWithDigest := getIntelImageWithDigest(name, img) + if imageWithDigest == "" { + // See above. Skip + break + } + imageRef := lockconfig.ImageRef{ + Image: imageWithDigest, + Annotations: map[string]string{ + carvelID: img.Image, + }, + } + imagesLock.AddImageRef(imageRef) + } + return imagesLock, nil +} + +func getIntelImageWithDigest(name string, img *imagelock.ChartImage) string { + + for _, digest := range img.Digests { + if digest.Arch == "linux/amd64" { + return fmt.Sprintf("%s@%s", name, digest.Digest.String()) + } + } + return "" +} diff --git a/pkg/chartutils/chart.go b/pkg/chartutils/chart.go new file mode 100644 index 0000000..a95e998 --- /dev/null +++ b/pkg/chartutils/chart.go @@ -0,0 +1,174 @@ +// Package chartutils implements helper functions to manipulate helm Charts +package chartutils + +import ( + "fmt" + "path/filepath" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +// Chart defines a helm Chart with extra functionalities +type Chart struct { + chart *chart.Chart + rootDir string + annotationsKey string + valuesFiles []string +} + +// ChartFullPath returns the wrapped chart ChartFullPath +func (c *Chart) ChartFullPath() string { + return c.chart.ChartFullPath() +} + +// Name returns the name of the chart +func (c *Chart) Name() string { + return c.chart.Name() +} + +// Version returns the version of the chart +func (c *Chart) Version() string { + return c.chart.Metadata.Version +} + +// Metadata returns the metadata of the chart +func (c *Chart) Metadata() *chart.Metadata { + return c.chart.Metadata +} + +// RootDir returns the Chart root directory +func (c *Chart) RootDir() string { + return c.rootDir +} + +// ChartDir returns the Chart root directory (required to implement wrapping.Unwrapable) +func (c *Chart) ChartDir() string { + return c.RootDir() +} + +// VerifyLock verifies the Images.lock file for the chart +func (c *Chart) VerifyLock(opts ...imagelock.Option) error { + chartPath := c.ChartDir() + if !utils.FileExists(chartPath) { + return fmt.Errorf("Helm chart %q does not exist", chartPath) + } + + currentLock, err := c.GetImagesLock() + if err != nil { + return fmt.Errorf("failed to load Images.lock: %w", err) + } + calculatedLock, err := imagelock.GenerateFromChart(chartPath, + opts..., + ) + + if err != nil { + return fmt.Errorf("failed to re-create Images.lock from Helm chart %q: %v", chartPath, err) + } + + if err := calculatedLock.Validate(currentLock.Images); err != nil { + return fmt.Errorf("Images.lock does not validate:\n%v", err) + } + return nil +} + +// Chart returns the Chart object (required to implement wrapping.Unwrapable) +func (c *Chart) Chart() *Chart { + return c +} + +// LockFilePath returns the absolute path to the chart Images.lock +func (c *Chart) LockFilePath() string { + return c.AbsFilePath(imagelock.DefaultImagesLockFileName) +} + +// GetImagesLock returns the chart's ImagesLock object +func (c *Chart) GetImagesLock() (*imagelock.ImagesLock, error) { + lockFile := c.LockFilePath() + + lock, err := imagelock.FromYAMLFile(lockFile) + if err != nil { + return nil, err + } + + return lock, nil +} + +// ImageArtifactsDir returns the imags artifacts directory +func (c *Chart) ImageArtifactsDir() string { + return filepath.Join(c.RootDir(), artifacts.HelmArtifactsFolder, "images") +} + +// ImagesDir returns the images directory inside the chart root directory +func (c *Chart) ImagesDir() string { + return filepath.Join(c.RootDir(), "images") +} + +// File returns the chart.File for the provided name or nil if not found +func (c *Chart) File(name string) *chart.File { + return getChartFile(c.chart, name) +} + +// ValuesFiles returns all the values chart.File +func (c *Chart) ValuesFiles() []*chart.File { + files := make([]*chart.File, 0, len(c.valuesFiles)) + for _, valuesFile := range c.valuesFiles { + files = append(files, c.File(valuesFile)) + } + return files +} + +// AbsFilePath returns the absolute path to the Chart relative file name +func (c *Chart) AbsFilePath(name string) string { + return filepath.Join(c.rootDir, name) +} + +// GetAnnotatedImages returns the chart images specified in the annotations +func (c *Chart) GetAnnotatedImages() (imagelock.ImageList, error) { + return imagelock.GetImagesFromChartAnnotations( + c.chart, + imagelock.NewImagesLockConfig( + imagelock.WithAnnotationsKey(c.annotationsKey), + ), + ) +} + +// Dependencies returns the chart dependencies +func (c *Chart) Dependencies() []*Chart { + cfg := NewConfiguration(WithAnnotationsKey(c.annotationsKey), WithValuesFiles(c.valuesFiles...)) + deps := make([]*Chart, 0) + + for _, dep := range c.chart.Dependencies() { + subChart := filepath.Join(c.RootDir(), "charts", dep.Name()) + deps = append(deps, newChart(dep, subChart, cfg)) + } + return deps +} + +// LoadChart returns the Chart defined by path +func LoadChart(path string, opts ...Option) (*Chart, error) { + cfg := NewConfiguration(opts...) + + chart, err := loader.Load(path) + if err != nil { + return nil, fmt.Errorf("failed to load Helm chart: %v", err) + } + chartRoot, err := GetChartRoot(path) + if err != nil { + return nil, fmt.Errorf("cannot determine Helm chart root: %v", err) + } + return newChart(chart, chartRoot, cfg), nil +} + +func newChart(c *chart.Chart, chartRoot string, cfg *Configuration) *Chart { + return &Chart{ + chart: c, + rootDir: chartRoot, + annotationsKey: cfg.AnnotationsKey, + valuesFiles: cfg.ValuesFiles, + } +} diff --git a/pkg/chartutils/chart_test.go b/pkg/chartutils/chart_test.go new file mode 100644 index 0000000..7067c55 --- /dev/null +++ b/pkg/chartutils/chart_test.go @@ -0,0 +1,126 @@ +package chartutils + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "gopkg.in/yaml.v3" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func (suite *ChartUtilsTestSuite) TestLoadChart() { + sb := suite.sb + t := suite.T() + type rawChartData struct { + Dependencies []struct { + Name string + Repository string + Version string + } + Annotations map[string]string + } + type imgAnnotation struct { + Name string + Image string + } + readRawChart := func(f string) (*rawChartData, error) { + fh, err := os.Open(f) + if err != nil { + return nil, fmt.Errorf("cannot open file %q: %v", f, err) + } + require.NoError(t, err) + defer fh.Close() + + d := &rawChartData{} + dec := yaml.NewDecoder(fh) + if err := dec.Decode(d); err != nil { + return nil, fmt.Errorf("cannot parse file %q: %v", f, err) + } + return d, nil + } + + t.Run("Working Scenarios", func(t *testing.T) { + chartDir := sb.TempFile() + serverURL := "localhost" + + require.NoError(t, tu.RenderScenario("../../testdata/scenarios/chart1", chartDir, map[string]interface{}{"ServerURL": serverURL})) + t.Run("Fail Scenarios", func(t *testing.T) { + t.Run("Fails to load non existing chart", func(t *testing.T) { + _, err := LoadChart(sb.TempFile()) + require.ErrorContains(t, err, "no such file or directory") + }) + + }) + t.Run("Loads a chart from a directory", func(t *testing.T) { + chart, err := LoadChart(chartDir) + require.NoError(t, err) + t.Run("RootDir", func(t *testing.T) { + assert.Equal(t, chart.RootDir(), chartDir) + }) + t.Run("AbsFilePath", func(t *testing.T) { + for _, tail := range []string{"Chart.yaml", "ImagesLock.lock"} { + assert.Equal(t, chart.AbsFilePath(tail), filepath.Join(chartDir, tail)) + + } + }) + t.Run("ValuesFile", func(t *testing.T) { + f := chart.ValuesFiles() + require.NotNil(t, f) + assert.Equal(t, len(f), 1) + assert.Equal(t, f[0].Name, "values.yaml") + }) + t.Run("Dependencies", func(t *testing.T) { + dependencies := chart.Dependencies() + d, err := readRawChart(filepath.Join(chartDir, "Chart.yaml")) + require.NoError(t, err) + assert.Equal(t, len(dependencies), len(d.Dependencies)) + OutLoop: + for _, depData := range d.Dependencies { + for _, dep := range dependencies { + if dep.Name() == depData.Name { + continue OutLoop + } + } + assert.Fail(t, "cannot find dependant chart %q", depData.Name) + } + }) + + t.Run("GetImageAnnotations", func(t *testing.T) { + res, err := chart.GetAnnotatedImages() + assert.NoError(t, err) + + d, err := readRawChart(filepath.Join(chartDir, "Chart.yaml")) + + require.NoError(t, err) + annotationsData, ok := d.Annotations["images"] + require.True(t, ok, "Cannot find images annotation") + + annBuff := bytes.NewBufferString(annotationsData) + imgAnnotations := make([]imgAnnotation, 0) + dec := yaml.NewDecoder(annBuff) + require.NoError(t, dec.Decode(&imgAnnotations)) + + require.Equal(t, len(imgAnnotations), len(res)) + + OutLoop: + for _, imgAnnotation := range imgAnnotations { + for _, img := range res { + if img.Name == imgAnnotation.Name && img.Image == imgAnnotation.Image { + continue OutLoop + } + } + assert.Fail(t, "Image %q was not found", imgAnnotation.Name) + } + + }) + + }) + }) + +} diff --git a/pkg/chartutils/chartutils.go b/pkg/chartutils/chartutils.go new file mode 100644 index 0000000..20e8b38 --- /dev/null +++ b/pkg/chartutils/chartutils.go @@ -0,0 +1,185 @@ +package chartutils + +import ( + "archive/tar" + "context" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + + "gopkg.in/yaml.v3" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +// ErrNoImagesToAnnotate is returned when the chart can't be annotated because +// there are no container images +var ErrNoImagesToAnnotate = errors.New("no container images to annotate found") + +// AnnotateChart parses the values.yaml file in the chart specified by chartPath and +// annotates the Chart with the list of found images +func AnnotateChart(chartPath string, opts ...Option) error { + var annotated bool + cfg := NewConfiguration(opts...) + chart, err := loader.Load(chartPath) + if err != nil { + return fmt.Errorf("failed to load Helm chart: %v", err) + } + + chartRoot, err := GetChartRoot(chartPath) + if err != nil { + return fmt.Errorf("cannot determine Helm chart root: %v", err) + } + + res, err := FindImageElementsInValuesFile(chartPath) + if err != nil { + return fmt.Errorf("failed to find image elements: %v", err) + } + + // Make sure order is always the same + sort.Sort(res) + if len(res) > 0 { + annotated = true + } + + chartFile := filepath.Join(chartRoot, "Chart.yaml") + + if err := writeAnnotationsToChart(res, chartFile, cfg); err != nil { + return fmt.Errorf("failed to serialize annotations: %v", err) + } + + var allErrors error + for _, dep := range chart.Dependencies() { + subChart := filepath.Join(chartRoot, "charts", dep.Name()) + if err := AnnotateChart(subChart, opts...); err != nil { + // Ignore the error if its ErrNoImagesToAnnotate + if !errors.Is(err, ErrNoImagesToAnnotate) { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to annotate sub-chart %q: %v", dep.ChartFullPath(), err)) + } + } else { + // No error means the dependency was annotated + annotated = true + } + } + + if !annotated && allErrors == nil { + return ErrNoImagesToAnnotate + } + + return allErrors +} + +// GetChartRoot returns the chart root directory to the chart provided (which may point to its Chart.yaml file) +func GetChartRoot(chartPath string) (string, error) { + fi, err := os.Stat(chartPath) + if err != nil { + return "", fmt.Errorf("cannot access path %q: %v", chartPath, err) + } + // we either got the path to chart dir, or to the chart yaml + if fi.IsDir() { + return filepath.Abs(chartPath) + } + return filepath.Abs(filepath.Dir(chartPath)) +} + +func writeAnnotationsToChart(set ValuesImageElementList, chartFile string, cfg *Configuration) error { + // Nothing to write + if len(set) == 0 { + return nil + } + imagesAnnotation, err := set.ToAnnotation() + if err != nil { + return fmt.Errorf("failed to create annotation text: %v", err) + } + + type YAMLData struct { + Annotations map[string]interface{} `yaml:"annotations"` + + Rest map[string]interface{} `yaml:",inline"` + } + + chartData, err := os.ReadFile(chartFile) + if err != nil { + log.Fatalf("Failed to unmarshal YAML: %v", err) + } + var data YAMLData + + // Unmarshal the YAML into the struct + err = yaml.Unmarshal(chartData, &data) + if err != nil { + log.Fatalf("Failed to unmarshal YAML: %v", err) + } + + // The map is nil if the chart does not contain annotations + if data.Annotations == nil { + data.Annotations = make(map[string]interface{}) + } + // Do any necessary modifications to the annotations field + data.Annotations[cfg.AnnotationsKey] = string(imagesAnnotation) + // Marshal the struct back into YAML + modifiedYAML, err := yaml.Marshal(&data) + if err != nil { + log.Fatalf("Failed to marshal YAML: %v", err) + } + return utils.SafeWriteFile(chartFile, modifiedYAML, 0600) +} + +func getChartFile(c *chart.Chart, name string) *chart.File { + for _, f := range c.Raw { + if f.Name == name { + return f + + } + } + return nil +} + +// GetImageLockFilePath returns the path to the Images.lock file for the chart +func GetImageLockFilePath(chartPath string) (string, error) { + chartRoot, err := GetChartRoot(chartPath) + if err != nil { + return "", err + } + + return filepath.Join(chartRoot, imagelock.DefaultImagesLockFileName), nil +} + +// IsRemoteChart returns true if the chart is a remote chart +func IsRemoteChart(path string) bool { + return strings.HasPrefix(path, "oci://") +} + +// ReadLockFromChart reads the Images.lock file from the chart +func ReadLockFromChart(chartPath string) (*imagelock.ImagesLock, error) { + var lock *imagelock.ImagesLock + var err error + if isTar, _ := utils.IsTarFile(chartPath); isTar { + if err := utils.FindFileInTar(context.Background(), chartPath, "Images.lock", func(tr *tar.Reader) error { + lock, err = imagelock.FromYAML(tr) + return err + }, utils.TarConfig{StripComponents: 1}); err != nil { + return nil, err + } + if lock == nil { + return nil, fmt.Errorf("Images.lock not found in wrap") + } + return lock, nil + } + + f, err := GetImageLockFilePath(chartPath) + if err != nil { + return nil, fmt.Errorf("failed to find Images.lock: %v", err) + } + if !utils.FileExists(f) { + return nil, fmt.Errorf("Images.lock file does not exist") + } + return imagelock.FromYAMLFile(f) +} diff --git a/pkg/chartutils/chartutils_test.go b/pkg/chartutils/chartutils_test.go new file mode 100644 index 0000000..ba4c3ae --- /dev/null +++ b/pkg/chartutils/chartutils_test.go @@ -0,0 +1,81 @@ +package chartutils + +import ( + "fmt" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type ChartUtilsTestSuite struct { + suite.Suite + sb *tu.Sandbox +} + +func (suite *ChartUtilsTestSuite) TearDownSuite() { + _ = suite.sb.Cleanup() +} + +func (suite *ChartUtilsTestSuite) SetupSuite() { + suite.sb = tu.NewSandbox() +} + +func TestChartUtilsTestSuite(t *testing.T) { + suite.Run(t, new(ChartUtilsTestSuite)) +} + +func (suite *ChartUtilsTestSuite) TestAnnotateChart() { + t := suite.T() + require := suite.Require() + + sb := suite.sb + serverURL := "localhost" + scenarioName := "plain-chart" + defaultAnnotationsKey := "images" + // customAnnotationsKey := "artifacthub.io/images" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + type testImage struct { + Name string + Registry string + Repository string + Tag string + Digest string + } + + images := []testImage{ + { + Name: "bitnami-shell", + Registry: "docker.io", + Repository: "bitnami/bitnami-shell", + Tag: "1.0.0", + }, + { + Name: "wordpress", + Registry: "docker.io", + Repository: "bitnami/wordpress", + Tag: "latest", + }, + } + t.Run("Annotates a chart", func(t *testing.T) { + chartDir := sb.TempFile() + annotationsKey := defaultAnnotationsKey + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "ValuesImages": images}, + )) + + expectedImages := make([]tu.AnnotationEntry, 0) + for _, img := range images { + url := fmt.Sprintf("%s/%s:%s", img.Registry, img.Repository, img.Tag) + expectedImages = append(expectedImages, tu.AnnotationEntry{ + Name: img.Name, + Image: url, + }) + } + + require.NoError(AnnotateChart(chartDir, WithAnnotationsKey(annotationsKey))) + tu.AssertChartAnnotations(t, chartDir, annotationsKey, expectedImages) + }) +} diff --git a/pkg/chartutils/images.go b/pkg/chartutils/images.go new file mode 100644 index 0000000..e038600 --- /dev/null +++ b/pkg/chartutils/images.go @@ -0,0 +1,316 @@ +package chartutils + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +func getNumberOfArtifacts(images imagelock.ImageList) int { + n := 0 + for _, imgDesc := range images { + n += len(imgDesc.Digests) + } + return n +} + +func getArtifactsDir(defaultValue string, cfg *Configuration) string { + if cfg.ArtifactsDir != "" { + return cfg.ArtifactsDir + } + return defaultValue +} + +// PullImages downloads the list of images specified in the provided ImagesLock +func PullImages(lock *imagelock.ImagesLock, imagesDir string, opts ...Option) error { + + cfg := NewConfiguration(opts...) + ctx := cfg.Context + + artifactsDir := getArtifactsDir(filepath.Join(imagesDir, "artifacts"), cfg) + allOpts := []crane.Option{ + crane.WithContext(ctx), + } + if cfg.InsecureMode { + allOpts = append(allOpts, crane.Insecure) + } + if cfg.Auth.Username != "" && cfg.Auth.Password != "" { + allOpts = append(allOpts, crane.WithAuth(&authn.Basic{Username: cfg.Auth.Username, Password: cfg.Auth.Password})) + } + o := crane.GetOptions(allOpts...) + + if err := os.MkdirAll(imagesDir, 0755); err != nil { + return fmt.Errorf("failed to create bundle directory: %v", err) + } + l := cfg.Log + + if len(lock.Images) == 0 { + return fmt.Errorf("no images found in Images.lock") + } + + p, _ := cfg.ProgressBar.WithTotal(getNumberOfArtifacts(lock.Images)).UpdateTitle("Pulling Images").Start() + defer p.Stop() + maxRetries := cfg.MaxRetries + + for _, imgDesc := range lock.Images { + for _, dgst := range imgDesc.Digests { + select { + // Early abort if the context is done + case <-ctx.Done(): + return fmt.Errorf("cancelled execution") + default: + p.Add(1) + p.UpdateTitle(fmt.Sprintf("Saving image %s/%s %s (%s)", imgDesc.Chart, imgDesc.Name, imgDesc.Image, dgst.Arch)) + err := utils.ExecuteWithRetry(maxRetries, func(try int, prevErr error) error { + if try > 0 { + // The context is done, so we are not retrying, just return the error + if ctx.Err() != nil { + return prevErr + } + l.Debugf("Failed to pull image: %v", prevErr) + p.Warnf("Failed to pull image: retrying %d/%d", try, maxRetries) + } + if _, err := pullImage(imgDesc.Image, dgst, imagesDir, o); err != nil { + return err + } + return nil + }) + + if err != nil { + return fmt.Errorf("failed to pull image %q: %w", imgDesc.Name, err) + } + } + } + if cfg.FetchArtifacts { + p.UpdateTitle(fmt.Sprintf("Saving image %s/%s signature", imgDesc.Chart, imgDesc.Name)) + if err := artifacts.PullImageSignatures(context.Background(), imgDesc, artifactsDir, artifacts.WithAuth(cfg.Auth.Username, cfg.Auth.Password)); err != nil { + if err == artifacts.ErrTagDoesNotExist { + l.Debugf("image %q does not have an associated signature", imgDesc.Image) + } else { + return fmt.Errorf("failed to fetch image signatures: %w", err) + } + } else { + l.Debugf("image %q signature fetched", imgDesc.Image) + } + p.UpdateTitle(fmt.Sprintf("Saving image %s/%s metadata", imgDesc.Chart, imgDesc.Name)) + if err := artifacts.PullImageMetadata(context.Background(), imgDesc, artifactsDir, artifacts.WithAuth(cfg.Auth.Username, cfg.Auth.Password)); err != nil { + if err == artifacts.ErrTagDoesNotExist { + l.Debugf("image %q does not have an associated metadata artifact", imgDesc.Image) + } else { + return fmt.Errorf("failed to fetch image metadata: %w", err) + } + } else { + l.Debugf("image %q metadata fetched", imgDesc.Image) + } + } + } + return nil +} + +// PushImages push the list of images in imagesDir to the destination specified in the ImagesLock +func PushImages(lock *imagelock.ImagesLock, imagesDir string, opts ...Option) error { + cfg := NewConfiguration(opts...) + l := cfg.Log + + ctx := cfg.Context + + artifactsDir := getArtifactsDir(filepath.Join(imagesDir, "artifacts"), cfg) + + p, _ := cfg.ProgressBar.WithTotal(len(lock.Images)).UpdateTitle("Pushing images").Start() + defer p.Stop() + + craneOpts := make([]crane.Option, 0) + craneOpts = append(craneOpts, crane.WithContext(ctx)) + if cfg.InsecureMode { + craneOpts = append(craneOpts, crane.Insecure) + } + if cfg.Auth.Username != "" && cfg.Auth.Password != "" { + craneOpts = append(craneOpts, crane.WithAuth(&authn.Basic{Username: cfg.Auth.Username, Password: cfg.Auth.Password})) + } + o := crane.GetOptions(craneOpts...) + + maxRetries := cfg.MaxRetries + for _, imgData := range lock.Images { + + select { + // Early abort if the context is done + case <-ctx.Done(): + return fmt.Errorf("cancelled execution") + default: + p.Add(1) + p.UpdateTitle(fmt.Sprintf("Pushing image %q", imgData.Image)) + err := utils.ExecuteWithRetry(maxRetries, func(try int, prevErr error) error { + if try > 0 { + // The context is done, so we are not retrying, just return the error + if ctx.Err() != nil { + return prevErr + } + l.Debugf("Failed to push image: %v", prevErr) + p.Warnf("Failed to push image: retrying %d/%d", try, maxRetries) + } + if err := pushImage(imgData, imagesDir, o); err != nil { + return err + } + if err := artifacts.PushImageSignatures(context.Background(), + imgData, + artifactsDir, + artifacts.WithAuth(cfg.Auth.Username, cfg.Auth.Password), + artifacts.WithInsecureMode(cfg.InsecureMode)); err != nil { + if err == artifacts.ErrLocalArtifactNotExist { + l.Debugf("image %q does not have a local signature stored", imgData.Image) + } else { + return fmt.Errorf("failed to push image signatures: %w", err) + } + } else { + p.UpdateTitle(fmt.Sprintf("Pushed image %q signature", imgData.Image)) + } + + if err := artifacts.PushImageMetadata(context.Background(), + imgData, + artifactsDir, + artifacts.WithAuth(cfg.Auth.Username, cfg.Auth.Password), + artifacts.WithInsecureMode(cfg.InsecureMode)); err != nil { + if err == artifacts.ErrLocalArtifactNotExist { + l.Debugf("image %q does not have a local metadata artifact stored", imgData.Image) + } else { + return fmt.Errorf("failed to push image metadata: %w", err) + } + } else { + p.UpdateTitle(fmt.Sprintf("Pushed image %q metadata", imgData.Image)) + } + return nil + }) + if err != nil { + return fmt.Errorf("failed to push image %q: %w", imgData.Name, err) + } + } + } + return nil +} + +func loadImage(path string) (v1.Image, error) { + stat, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !stat.IsDir() { + img, err := crane.Load(path) + if err != nil { + return nil, fmt.Errorf("could not load %q as tarball: %w", path, err) + } + return img, nil + } + + l, err := layout.ImageIndexFromPath(path) + if err != nil { + return nil, fmt.Errorf("could load %q as OCI layout: %w", path, err) + } + m, err := l.IndexManifest() + if err != nil { + return nil, err + } + if len(m.Manifests) != 1 { + return nil, fmt.Errorf("layout contains too many entries (%d)", len(m.Manifests)) + } + desc := m.Manifests[0] + if desc.MediaType.IsImage() { + return l.Image(desc.Digest) + } + return nil, fmt.Errorf("layout contains non-image (mediaType: %q)", desc.MediaType) +} + +func buildImageIndex(image *imagelock.ChartImage, imagesDir string) (v1.ImageIndex, error) { + adds := make([]mutate.IndexAddendum, 0, len(image.Digests)) + + base := mutate.IndexMediaType(empty.Index, types.DockerManifestList) + for _, dgstData := range image.Digests { + imgDir := getImageLayoutDir(imagesDir, dgstData) + + img, err := loadImage(imgDir) + if err != nil { + return nil, fmt.Errorf("failed to load image %q: %w", imgDir, err) + } + newDesc, err := partial.Descriptor(img) + if err != nil { + return nil, fmt.Errorf("failed to create descriptor: %w", err) + } + cf, err := img.ConfigFile() + if err != nil { + return nil, fmt.Errorf("failed to obtain image config file: %w", err) + } + newDesc.Platform = cf.Platform() + + adds = append(adds, mutate.IndexAddendum{ + Add: img, + Descriptor: *newDesc, + }) + } + return mutate.AppendManifests(base, adds...), nil +} + +func pushImage(imgData *imagelock.ChartImage, imagesDir string, o crane.Options) error { + idx, err := buildImageIndex(imgData, imagesDir) + if err != nil { + return fmt.Errorf("failed to build image index: %w", err) + } + + ref, err := name.ParseReference(imgData.Image, o.Name...) + if err != nil { + return fmt.Errorf("failed to parse image reference %q: %w", imgData.Image, err) + } + + if err := remote.WriteIndex(ref, idx, o.Remote...); err != nil { + return fmt.Errorf("failed to write image index: %w", err) + } + + return nil +} + +func getImageLayoutDir(imagesDir string, dgst imagelock.DigestInfo) string { + return filepath.Join(imagesDir, fmt.Sprintf("%s.layout", dgst.Digest.Encoded())) +} + +func pullImage(image string, digest imagelock.DigestInfo, imagesDir string, o crane.Options) (string, error) { + imgDir := getImageLayoutDir(imagesDir, digest) + + src := fmt.Sprintf("%s@%s", image, digest.Digest) + ref, err := name.ParseReference(src, o.Name...) + if err != nil { + return "", fmt.Errorf("parsing reference %q: %w", src, err) + } + rmt, err := remote.Get(ref, o.Remote...) + if err != nil { + return "", err + } + img, err := rmt.Image() + if err != nil { + return "", err + } + // We do not want to keep adding images to the index so we + // start fresh + if utils.FileExists(imgDir) { + if err := os.RemoveAll(imgDir); err != nil { + return "", fmt.Errorf("failed to remove existing image dir %q: %v", imgDir, err) + } + } + if err := crane.SaveOCI(img, imgDir); err != nil { + return "", fmt.Errorf("failed to save image %q to %q: %w", image, imgDir, err) + } + return imgDir, nil +} diff --git a/pkg/chartutils/images_test.go b/pkg/chartutils/images_test.go new file mode 100644 index 0000000..935fdf7 --- /dev/null +++ b/pkg/chartutils/images_test.go @@ -0,0 +1,176 @@ +package chartutils + +import ( + "fmt" + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/registry" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" +) + +func (suite *ChartUtilsTestSuite) TestPullImages() { + require := suite.Require() + t := suite.T() + + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + + imageName := "test:mytag" + + images, err := tu.AddSampleImagesToRegistry(imageName, u.Host) + if err != nil { + t.Fatal(err) + } + + sb := suite.sb + + serverURL := u.Host + scenarioName := "complete-chart" + chartName := "test" + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + t.Run("Pulls images", func(_ *testing.T) { + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, + )) + imagesDir := filepath.Join(chartDir, "images") + + require.NoError(err) + + lock, err := imagelock.FromYAMLFile(filepath.Join(chartDir, "Images.lock")) + require.NoError(err) + require.NoError(PullImages(lock, imagesDir)) + + require.DirExists(imagesDir) + + for _, imgData := range images { + for _, digestData := range imgData.Digests { + imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", digestData.Digest.Encoded())) + suite.Assert().DirExists(imgDir) + } + } + }) + + t.Run("Error when no images in Images.lock", func(_ *testing.T) { + chartDir := sb.TempFile() + + images := []tu.ImageData{} + scenarioName := "no-images-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "RepositoryURL": serverURL}, + )) + imagesDir := filepath.Join(chartDir, "images") + + require.NoError(err) + + lock, err := imagelock.FromYAMLFile(filepath.Join(chartDir, "Images.lock")) + require.NoError(err) + require.Error(PullImages(lock, imagesDir)) + + require.DirExists(imagesDir) + + for _, imgData := range images { + for _, digestData := range imgData.Digests { + imgDir := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", digestData.Digest.Encoded())) + suite.Assert().DirExists(imgDir) + } + } + }) +} + +func (suite *ChartUtilsTestSuite) TestPushImages() { + + t := suite.T() + sb := suite.sb + require := suite.Require() + assert := suite.Assert() + + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + serverURL := u.Host + + t.Run("Pushing works", func(t *testing.T) { + scenarioName := "complete-chart" + chartName := "test" + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + imageData := tu.ImageData{Name: "test", Image: "test:mytag"} + architectures := []string{ + "linux/amd64", + "linux/arm", + } + craneImgs, err := tu.CreateSampleImages(&imageData, architectures) + + if err != nil { + t.Fatal(err) + } + + require.Equal(len(architectures), len(imageData.Digests)) + + images := []tu.ImageData{imageData} + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, "Images": images, + "Name": chartName, "RepositoryURL": serverURL, + }, + )) + + imagesDir := filepath.Join(chartDir, "images") + require.NoError(os.MkdirAll(imagesDir, 0755)) + for _, img := range craneImgs { + d, err := img.Digest() + if err != nil { + t.Fatal(err) + } + imgFile := filepath.Join(imagesDir, fmt.Sprintf("%s.layout", d.Hex)) + if err := crane.SaveOCI(img, imgFile); err != nil { + t.Fatal(err) + } + } + + t.Run("Push images", func(t *testing.T) { + require.NoError(err) + lock, err := imagelock.FromYAMLFile(filepath.Join(chartDir, "Images.lock")) + require.NoError(err) + require.NoError(PushImages(lock, imagesDir)) + + // Verify the images were pushed + for _, img := range images { + src := fmt.Sprintf("%s/%s", u.Host, img.Image) + remoteDigests, err := tu.ReadRemoteImageManifest(src) + if err != nil { + t.Fatal(err) + } + for _, dgstData := range img.Digests { + assert.Equal(dgstData.Digest.Hex(), remoteDigests[dgstData.Arch].Digest.Hex()) + } + } + }) + }) +} diff --git a/pkg/chartutils/options.go b/pkg/chartutils/options.go new file mode 100644 index 0000000..e4dd64a --- /dev/null +++ b/pkg/chartutils/options.go @@ -0,0 +1,126 @@ +package chartutils + +import ( + "context" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" +) + +// Auth defines the authentication settings +type Auth struct { + Username string + Password string +} + +// Configuration defines configuration settings used in chartutils functions +type Configuration struct { + AnnotationsKey string + Log log.Logger + Context context.Context + ProgressBar log.ProgressBar + ArtifactsDir string + FetchArtifacts bool + MaxRetries int + InsecureMode bool + Auth Auth + ValuesFiles []string +} + +// WithInsecureMode configures Insecure transport +func WithInsecureMode(insecure bool) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.InsecureMode = insecure + } +} + +// WithAuth configures the Auth +func WithAuth(username, password string) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.Auth = Auth{ + Username: username, + Password: password, + } + } +} + +// WithArtifactsDir configures the ArtifactsDir +func WithArtifactsDir(dir string) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.ArtifactsDir = dir + } +} + +// WithFetchArtifacts configures the FetchArtifacts setting +func WithFetchArtifacts(fetch bool) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.FetchArtifacts = fetch + } +} + +// WithContext provides an execution context +func WithContext(ctx context.Context) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.Context = ctx + } +} + +// WithMaxRetries configures the number of retries on error +func WithMaxRetries(retries int) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.MaxRetries = retries + } +} + +// WithProgressBar provides a ProgressBar for long running operations +func WithProgressBar(pb log.ProgressBar) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.ProgressBar = pb + } +} + +// NewConfiguration returns a new Configuration +func NewConfiguration(opts ...Option) *Configuration { + cfg := &Configuration{ + AnnotationsKey: imagelock.DefaultAnnotationsKey, + Context: context.Background(), + ProgressBar: silent.NewProgressBar(), + ArtifactsDir: "", + FetchArtifacts: false, + MaxRetries: 3, + Log: silent.NewLogger(), + InsecureMode: false, + ValuesFiles: []string{"values.yaml"}, + } + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// Option defines a configuration option +type Option func(c *Configuration) + +// WithLog provides a log to use +func WithLog(l log.Logger) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.Log = l + } +} + +// WithAnnotationsKey customizes the annotations key to use when reading/writing images +// to the Chart.yaml +func WithAnnotationsKey(str string) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.AnnotationsKey = str + } +} + +// WithValuesFiles customizes the values files in the chart +func WithValuesFiles(files ...string) func(cfg *Configuration) { + return func(cfg *Configuration) { + cfg.ValuesFiles = files + } +} diff --git a/pkg/chartutils/values.go b/pkg/chartutils/values.go new file mode 100644 index 0000000..3783587 --- /dev/null +++ b/pkg/chartutils/values.go @@ -0,0 +1,197 @@ +package chartutils + +import ( + "bytes" + "fmt" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + "gopkg.in/yaml.v2" + + "helm.sh/helm/v3/pkg/chart/loader" +) + +var ( + imageElementKeys = []string{"registry", "repository", "tag", "digest"} +) + +// ValuesImageElement defines a docker image element definition found +// when parsing values.yaml +type ValuesImageElement struct { + locationPath string + Registry string + Repository string + Digest string + Tag string + + foundFields []string +} + +// YamlLocationPath returns the jsonpath-like location of the element in values.yaml +func (v *ValuesImageElement) YamlLocationPath() string { + return v.locationPath +} + +// Name returns the image name +func (v *ValuesImageElement) Name() string { + return filepath.Base(v.Repository) +} + +// ValuesImageElementList defines a list of ValuesImageElement +type ValuesImageElementList []*ValuesImageElement + +func (imgs ValuesImageElementList) Len() int { return len(imgs) } +func (imgs ValuesImageElementList) Swap(i, j int) { imgs[i], imgs[j] = imgs[j], imgs[i] } +func (imgs ValuesImageElementList) Less(i, j int) bool { + return imgs[i].Name() < imgs[j].Name() +} + +// ToAnnotation returns the annotation text representation of the ValuesImageElementList +func (imgs ValuesImageElementList) ToAnnotation() ([]byte, error) { + done := make(map[string]struct{}, 0) + rawData := make([]map[string]string, 0) + for _, img := range imgs { + url := img.URL() + if _, alreadyDone := done[url]; alreadyDone { + continue + } + rawData = append(rawData, map[string]string{ + "name": img.Name(), + "image": url, + }) + done[url] = struct{}{} + } + buf := &bytes.Buffer{} + enc := yaml.NewEncoder(buf) + if err := enc.Encode(rawData); err != nil { + return nil, fmt.Errorf("failed to serialize as annotation yaml: %v", err) + } + return buf.Bytes(), nil +} + +// URL returns the full URL to the image +func (v *ValuesImageElement) URL() string { + var url string + if v.Registry != "" { + url = fmt.Sprintf("%s/%s", v.Registry, v.Repository) + } else { + url = v.Repository + } + if v.Tag != "" { + url = fmt.Sprintf("%s:%s", url, v.Tag) + } + if v.Digest != "" { + url = fmt.Sprintf("%s@%s", url, v.Digest) + } + return url +} + +// ToMap returns the map[string]string representation of the ValuesImageElement +func (v *ValuesImageElement) ToMap() map[string]string { + return map[string]string{ + "registry": v.Registry, + "repository": v.Repository, + "digest": v.Digest, + "tag": v.Tag, + } +} + +// YamlReplaceMap returns the yaml paths to the different image definition elements +// and the current value +func (v *ValuesImageElement) YamlReplaceMap() map[string]string { + data := make(map[string]string, 0) + fullMap := v.ToMap() + // We should only write back what we found + for _, key := range v.foundFields { + value := fullMap[key] + p := fmt.Sprintf("%s.%s", v.YamlLocationPath(), key) + data[p] = value + } + return data +} + +// Relocate modifies the ValuesImageElement Registry and Repository based on the provided prefix +func (v *ValuesImageElement) Relocate(prefix string) error { + newURL, err := utils.RelocateImageURL(v.URL(), prefix, false) + if err != nil { + return fmt.Errorf("failed to relocate") + } + + newRef, err := name.ParseReference(newURL) + if err != nil { + return fmt.Errorf("failed to parse relocated URL: %v", err) + } + + v.Registry = newRef.Context().Registry.RegistryStr() + v.Repository = newRef.Context().RepositoryStr() + return nil +} + +// FindImageElementsInValuesMap parses the provided data looking for ValuesImageElement and returns the list +func FindImageElementsInValuesMap(data map[string]interface{}) (ValuesImageElementList, error) { + return findImageElementsInMap(data, "$"), nil +} + +// FindImageElementsInValuesFile looks for a list of ValuesImageElement in the +// values.yaml for the specified chartPath +func FindImageElementsInValuesFile(chartPath string) (ValuesImageElementList, error) { + c, err := loader.Load(chartPath) + if err != nil { + return nil, fmt.Errorf("failed to load Helm chart: %v", err) + } + return FindImageElementsInValuesMap(c.Values) +} + +func valuesImageElementFromMap(elemData map[string]string) *ValuesImageElement { + foundFields := make([]string, 0) + for _, k := range imageElementKeys { + if _, ok := elemData[k]; !ok { + elemData[k] = "" + } else { + foundFields = append(foundFields, k) + } + } + return &ValuesImageElement{ + foundFields: foundFields, + Registry: elemData["registry"], + Repository: elemData["repository"], + Digest: elemData["digest"], + Tag: elemData["tag"], + } +} + +func findImageElementsInMap(data map[string]interface{}, id string) []*ValuesImageElement { + elements := make([]*ValuesImageElement, 0) + if elem := parseValuesImageElement(data); elem != nil { + elem.locationPath = id + elements = append(elements, elem) + } + + for k, v := range data { + if v, ok := v.(map[string]interface{}); ok { + elements = append(elements, findImageElementsInMap(v, fmt.Sprintf("%s.%s", id, k))...) + } + } + return elements +} + +func parseValuesImageElement(data map[string]interface{}) *ValuesImageElement { + elemData := make(map[string]string) + for _, k := range imageElementKeys { + v, ok := data[k] + if !ok { + // digest and registry are optional + if k == "digest" || k == "registry" { + continue + } + return nil + } + vStr, ok := v.(string) + if !ok { + return nil + } + elemData[k] = vStr + } + return valuesImageElementFromMap(elemData) +} diff --git a/pkg/imagelock/digest.go b/pkg/imagelock/digest.go new file mode 100644 index 0000000..619824b --- /dev/null +++ b/pkg/imagelock/digest.go @@ -0,0 +1,128 @@ +package imagelock + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/opencontainers/go-digest" +) + +// DigestInfo defines the digest information for an Architecture +type DigestInfo struct { + Digest digest.Digest + Arch string +} + +func fetchImageDigests(r string, cfg *Config) ([]DigestInfo, error) { + opts := make([]crane.Option, 0) + if cfg.InsecureMode { + opts = append(opts, crane.Insecure) + } + opts = append(opts, crane.WithContext(cfg.Context)) + if cfg.Auth.Username != "" && cfg.Auth.Password != "" { + opts = append(opts, crane.WithAuth(&authn.Basic{Username: cfg.Auth.Username, Password: cfg.Auth.Password})) + } + + desc, err := GetImageRemoteDescriptor(r, opts...) + if err != nil { + return nil, fmt.Errorf("failed to get descriptor: %v", err) + } + + switch desc.MediaType { + + case types.OCIImageIndex, types.DockerManifestList: + var idx v1.IndexManifest + if err := json.Unmarshal(desc.Manifest, &idx); err != nil { + return nil, fmt.Errorf("failed to parse images data") + } + digests, err := readDigestsInfoFromIndex(idx) + if err != nil { + return nil, fmt.Errorf("failed to parse multi-arch image digests from remote descriptor: %w", err) + } + return digests, nil + case types.OCIManifestSchema1, types.DockerManifestSchema2: + img, err := desc.Image() + if err != nil { + return nil, fmt.Errorf("faild to get image from descriptor: %w", err) + } + digest, err := readDigestInfoFromImage(img) + if err != nil { + return nil, fmt.Errorf("failed to parse image digest from remote descriptor: %w", err) + } + return []DigestInfo{digest}, nil + + default: + return nil, fmt.Errorf("unknown media type %q", desc.MediaType) + } +} + +// GetImageRemoteDescriptor returns the image descriptor +func GetImageRemoteDescriptor(image string, opts ...crane.Option) (*remote.Descriptor, error) { + o := crane.GetOptions(opts...) + + ref, err := name.ParseReference(image, o.Name...) + + if err != nil { + return nil, fmt.Errorf("failed to parse reference %q: %w", image, err) + } + return remote.Get(ref, o.Remote...) +} + +func readDigestsInfoFromIndex(idx v1.IndexManifest) ([]DigestInfo, error) { + digests := make([]DigestInfo, 0) + + var allErrors error + + for _, img := range idx.Manifests { + // Skip attestations + if img.Annotations["vnd.docker.reference.type"] == "attestation-manifest" { + continue + } + switch img.MediaType { + case types.OCIManifestSchema1, types.DockerManifestSchema2: + platform := img.Platform + if platform == nil { + allErrors = errors.Join(allErrors, fmt.Errorf("image does not define a platform")) + continue + } + imgDigest := DigestInfo{ + Digest: digest.Digest(img.Digest.String()), + Arch: fmt.Sprintf("%s/%s", platform.OS, platform.Architecture), + } + digests = append(digests, imgDigest) + default: + allErrors = errors.Join(allErrors, fmt.Errorf("unknown media type %q", img.MediaType)) + continue + } + } + return digests, allErrors +} + +func readDigestInfoFromImage(img v1.Image) (DigestInfo, error) { + conf, err := img.ConfigFile() + if err != nil { + return DigestInfo{}, fmt.Errorf("faild to get image config: %w", err) + } + + platform := conf.Platform() + if platform == nil { + return DigestInfo{}, fmt.Errorf("failed to obtain image platform") + } + + digestData, err := img.Digest() + if err != nil { + return DigestInfo{}, fmt.Errorf("failed to get image digest: %w", err) + } + + return DigestInfo{ + Arch: fmt.Sprintf("%s/%s", platform.OS, platform.Architecture), + Digest: digest.Digest(digestData.String()), + }, nil +} diff --git a/pkg/imagelock/image.go b/pkg/imagelock/image.go new file mode 100644 index 0000000..9155525 --- /dev/null +++ b/pkg/imagelock/image.go @@ -0,0 +1,170 @@ +package imagelock + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "golang.org/x/exp/slices" + "gopkg.in/yaml.v3" + + "helm.sh/helm/v3/pkg/chart" +) + +// ChartImage represents an chart image with its associated information. +type ChartImage struct { + Name string // The name of the image. + Image string // The image reference. + Chart string // The chart containing the image. + Digests []DigestInfo // List of image digests associated with the image. +} + +// ImageList defines a list of images +type ImageList []*ChartImage + +// Dedup returns a cleaned ImageSet removing duplicates +func (imgs ImageList) Dedup() ImageList { + newImages := make([]*ChartImage, 0) + done := make(map[string]struct{}) + for _, img := range imgs { + id := fmt.Sprintf("%s:%s:%s", img.Chart, img.Name, img.Image) + if _, found := done[id]; found { + continue + } + done[id] = struct{}{} + newImages = append(newImages, img) + } + return newImages +} + +// ToAnnotation returns the annotation text describing the list of +// named images, ready to be inserted in the Chart.yaml file +func (imgs ImageList) ToAnnotation() ([]byte, error) { + type rawDataElem struct { + Name string + Image string + } + rawData := make([]rawDataElem, 0) + for _, img := range imgs { + rawData = append(rawData, rawDataElem{Name: img.Name, Image: img.Image}) + } + buf := &bytes.Buffer{} + enc := yaml.NewEncoder(buf) + if err := enc.Encode(rawData); err != nil { + return nil, fmt.Errorf("failed to serialize as annotation yaml: %v", err) + } + return buf.Bytes(), nil +} + +// Diff returns an error if the Image is not equivalent to the provided one +func (i *ChartImage) Diff(other *ChartImage) error { + var allErrors error + if i.Image != other.Image { + return fmt.Errorf("images do not match") + } + for _, digest := range other.Digests { + existingDigest, err := i.GetDigestForArch(digest.Arch) + if err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("Helm chart %q: image %q: %v", other.Chart, other.Image, err)) + continue + } + if existingDigest.Digest != digest.Digest { + allErrors = errors.Join(allErrors, + fmt.Errorf("Helm chart %q: image %q: digests do not match:\n- %s\n+ %s", + other.Chart, other.Image, digest.Digest, existingDigest.Digest)) + continue + } + } + return allErrors +} + +// GetDigestForArch returns the image digest for the specified architecture. +// It searches through the image's digests and returns the first digest that matches the given architecture. +// If no matching digest is found, it returns an error. +func (i *ChartImage) GetDigestForArch(arch string) (*DigestInfo, error) { + for _, digest := range i.Digests { + if digest.Arch == arch { + return &digest, nil + } + } + return nil, fmt.Errorf("failed to find digest for arch %q", arch) +} + +// FetchDigests fetches the image digests for the image from upstream. +// It updates the Image's Digests field with the fetched digests. +// If an error occurs during the fetch, it returns the error. +func (i *ChartImage) FetchDigests(cfg *Config) error { + digests, err := fetchImageDigests(i.Image, cfg) + if err != nil { + return err + } + filteredDigests := filterDigestsByPlatforms(digests, cfg.Platforms) + if len(filteredDigests) == 0 { + return fmt.Errorf("got empty list of digests after applying platforms filter %q", strings.Join(cfg.Platforms, ", ")) + } + i.Digests = filteredDigests + return nil +} + +// GetImagesFromChartAnnotations reads the images annotation from the chart (if present) and returns a list of +// ChartImage +func GetImagesFromChartAnnotations(c *chart.Chart, cfg *Config) (ImageList, error) { + images := make([]*ChartImage, 0) + + annotationsKey := cfg.AnnotationsKey + if annotationsKey == "" { + annotationsKey = DefaultAnnotationsKey + } + + imgsData, ok := c.Metadata.Annotations[annotationsKey] + + // Is perfectly fine to just return an empty list + // if the key is not there + if !ok { + return images, nil + } + + err := yaml.Unmarshal([]byte(imgsData), &images) + if err != nil { + return images, fmt.Errorf("failed to parse images metadata: %v", err) + } + + // Fill up chart ownership + for _, img := range images { + img.Chart = c.Name() + } + + return images, nil +} + +// getDigestedImagesFromChartAnnotations reads the images from the chart annotations and fills up +// the per-architecture digests for the images based on the remote registry +func getDigestedImagesFromChartAnnotations(c *chart.Chart, cfg *Config) (ImageList, error) { + var allErrors error + images, err := GetImagesFromChartAnnotations(c, cfg) + if err != nil { + return nil, fmt.Errorf("failed to get image list: %w", err) + } + for _, image := range images { + if err := image.FetchDigests(cfg); err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to fetch image %q digests: %w", image.Name, err)) + } + } + return images, allErrors +} + +func filterDigestsByPlatforms(digests []DigestInfo, platforms []string) []DigestInfo { + // If we do not ask for anything, we get all + if len(platforms) == 0 { + return digests + } + + filteredDigests := make([]DigestInfo, 0) + for _, d := range digests { + if slices.Contains(platforms, d.Arch) { + filteredDigests = append(filteredDigests, d) + } + } + return filteredDigests +} diff --git a/pkg/imagelock/image_test.go b/pkg/imagelock/image_test.go new file mode 100644 index 0000000..0f466a0 --- /dev/null +++ b/pkg/imagelock/image_test.go @@ -0,0 +1,33 @@ +package imagelock + +import ( + "fmt" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImageList_ToAnnotation(t *testing.T) { + images := ImageList{ + { + Image: "app:latest", + Name: "app1", + }, + { + Image: "blog:v2", + Name: "app2", + }, + } + t.Run("ImageList serializes as annotation", func(t *testing.T) { + expected := "" + for _, img := range images { + expected += fmt.Sprintf("- name: %s\n image: %s\n", img.Name, img.Image) + } + got, err := images.ToAnnotation() + require.NoError(t, err) + assert.Equal(t, tu.MustNormalizeYAML(expected), tu.MustNormalizeYAML(string(got))) + }) +} diff --git a/pkg/imagelock/lock.go b/pkg/imagelock/lock.go new file mode 100644 index 0000000..077f019 --- /dev/null +++ b/pkg/imagelock/lock.go @@ -0,0 +1,168 @@ +// Package imagelock implements utility routines for manipulating Images.lock +// files +package imagelock + +import ( + "errors" + "fmt" + "io" + "os" + "time" + + "gopkg.in/yaml.v3" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +// APIVersionV0 is the initial version of the API +const APIVersionV0 = "v0" + +// DefaultImagesLockFileName is the default lock file name +const DefaultImagesLockFileName = "Images.lock" + +// DefaultAnnotationsKey is the default annotations key used to include the images metadata +const DefaultAnnotationsKey = "images" + +// ImagesLock represents the lock file containing information about the included images. +type ImagesLock struct { + APIVersion string `yaml:"apiVersion"` // The version of the API used for the lock file. + Kind string // The type of object represented by the lock file. + Metadata map[string]string // Additional metadata associated with the lock file. + + Chart struct { + Name string // The name of the chart. + Version string // The version of the chart. + AppVersion string `yaml:"appVersion"` // The version of the app contained in the chart + } // Information about the chart associated with the lock file. + + Images ImageList // List of included images +} + +// FindImageByName finds a included Image based on its name and containing chart +func (il *ImagesLock) FindImageByName(chartName string, imageName string) (*ChartImage, error) { + return il.findImage(chartName, imageName) +} + +// findImage finds a included Image based on its name and containing chart and optionally, Image URL +func (il *ImagesLock) findImage(chartName string, imageName string, extra ...string) (*ChartImage, error) { + matchImageURL := false + imageURL := "" + if len(extra) > 0 { + imageURL = extra[0] + matchImageURL = true + } + for _, img := range il.Images { + if img.Chart == chartName && img.Name == imageName && (!matchImageURL || img.Image == imageURL) { + return img, nil + } + } + return nil, fmt.Errorf("cannot find image %q", imageName) +} + +// Validate checks if the provided list of images matches the contained set +func (il *ImagesLock) Validate(expectedImages ImageList) error { + var allErrors error + if len(il.Images) != len(expectedImages) { + allErrors = errors.Join(allErrors, fmt.Errorf("number of images differs: %d != %d", len(il.Images), len(expectedImages))) + } + for _, img := range expectedImages { + existingImg, err := il.findImage(img.Chart, img.Name, img.Image) + if err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("chart %q: %v", img.Chart, err)) + continue + } + if err := existingImg.Diff(img); err != nil { + allErrors = errors.Join(allErrors, err) + } + } + return allErrors +} + +// ToYAML writes the serialized YAML representation of the ImagesLock to w +func (il *ImagesLock) ToYAML(w io.Writer) error { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + + return enc.Encode(il) +} + +// NewImagesLock creates a new empty ImagesLock +func NewImagesLock() *ImagesLock { + return &ImagesLock{ + APIVersion: APIVersionV0, + Kind: "ImagesLock", + Metadata: map[string]string{"generatedAt": time.Now().UTC().Format("2006-01-02T15:04:05.999999999Z"), "generatedBy": "Distribution Tooling for Helm"}, + Images: make([]*ChartImage, 0), + } +} + +// FromYAML reads a ImagesLock from the YAML read from r +func FromYAML(r io.Reader) (*ImagesLock, error) { + il := NewImagesLock() + dec := yaml.NewDecoder(r) + if err := dec.Decode(il); err != nil { + return nil, fmt.Errorf("failed to load image-lock: %v", err) + } + + return il, nil +} + +// FromYAMLFile reads a ImagesLock from the YAML file +func FromYAMLFile(file string) (*ImagesLock, error) { + fh, err := os.Open(file) + if err != nil { + return nil, fmt.Errorf("failed to open Images.lock file: %w", err) + } + defer fh.Close() + return FromYAML(fh) +} + +// GenerateFromChart creates a ImagesLock from the Chart at chartPath +func GenerateFromChart(chartPath string, opts ...Option) (*ImagesLock, error) { + cfg := NewImagesLockConfig(opts...) + + chart, err := loader.Load(chartPath) + if err != nil { + return nil, fmt.Errorf("failed to load Helm chart: %v", err) + } + + imgLock := NewImagesLock() + + imgLock.Chart.Name = chart.Name() + imgLock.Chart.Version = chart.Metadata.Version + imgLock.Chart.AppVersion = chart.Metadata.AppVersion + + if err := populateImagesFromChart(imgLock, chart, cfg); err != nil { + return nil, err + } + + return imgLock, nil +} + +// populateImagesFromChart populates the ImagesLock with images and digests from the given chart and its dependencies. +func populateImagesFromChart(imgLock *ImagesLock, chart *chart.Chart, cfg *Config) error { + + images, err := getDigestedImagesFromChartAnnotations(chart, cfg) + if err != nil { + return fmt.Errorf("failed to process Helm chart %q images: %v", chart.Name(), err) + } + + imgLock.Images = append(imgLock.Images, images...) + + if len(chart.Dependencies()) == 0 && len(chart.Metadata.Dependencies) > 0 { + return fmt.Errorf("the Helm chart defines dependencies but they are not present in the charts directory") + } + var allErrors error + + for _, c := range chart.Dependencies() { + err := populateImagesFromChart(imgLock, c, cfg) + if err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to process Helm chart %q images: %v", c.Name(), err)) + continue + } + } + imgLock.Images = imgLock.Images.Dedup() + + return allErrors +} diff --git a/pkg/imagelock/lock_test.go b/pkg/imagelock/lock_test.go new file mode 100644 index 0000000..a0a8561 --- /dev/null +++ b/pkg/imagelock/lock_test.go @@ -0,0 +1,513 @@ +package imagelock + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/registry" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +func createLockFromImageData(images map[string][]*tu.ImageData) *ImagesLock { + lock := NewImagesLock() + for chartName, imgs := range images { + for _, img := range imgs { + chartImage := &ChartImage{ + Chart: chartName, + Image: img.Image, + Name: img.Name, + Digests: make([]DigestInfo, 0), + } + for _, digestInfo := range img.Digests { + chartImage.Digests = append(chartImage.Digests, DigestInfo{ + Arch: digestInfo.Arch, + Digest: digestInfo.Digest, + }) + } + lock.Images = append(lock.Images, chartImage) + } + } + return lock +} + +func initializeReferenceImages() ([]*tu.ImageData, error) { + var referenceImages []*tu.ImageData + + fh, err := os.Open("../../testdata/images.json") + if err != nil { + return nil, err + } + defer fh.Close() + dec := json.NewDecoder(fh) + if err := dec.Decode(&referenceImages); err != nil { + return nil, fmt.Errorf("failed to decode reference images: %w", err) + } + return referenceImages, nil +} + +func getImageLockImage(raw tu.ImageData, chart string) *ChartImage { + img := &ChartImage{ + Name: raw.Name, + Chart: chart, + Digests: make([]DigestInfo, 0), + Image: raw.Image, + } + for _, d := range raw.Digests { + img.Digests = append(img.Digests, DigestInfo{Arch: d.Arch, Digest: d.Digest}) + } + return img +} + +type ImageLockTestSuite struct { + suite.Suite + sb *tu.Sandbox + testServer *tu.TestServer + referenceImages []*tu.ImageData +} + +func (suite *ImageLockTestSuite) findImageByName(imageName string) (*tu.ImageData, error) { + for _, ref := range suite.referenceImages { + if ref.Name == imageName { + return ref, nil + } + } + return nil, fmt.Errorf("cannot find reference image %q", imageName) +} + +func (suite *ImageLockTestSuite) getCustomizedReferenceImages(chart string, names ...string) ([]*ChartImage, error) { + imgs := make([]*ChartImage, 0) + + for _, id := range names { + imgData, err := suite.findImageByName(id) + if err != nil { + return nil, err + } + + img := getImageLockImage(*imgData, chart) + img.Image = fmt.Sprintf("%s/%s", suite.testServer.ServerURL, imgData.Image) + imgs = append(imgs, img) + } + return imgs, nil +} + +func (suite *ImageLockTestSuite) TearDownSuite() { + suite.testServer.Close() + _ = suite.sb.Cleanup() +} + +func (suite *ImageLockTestSuite) SetupSuite() { + suite.sb = tu.NewSandbox() + s, err := tu.NewTestServer() + suite.Require().NoError(err) + + images, err := initializeReferenceImages() + suite.Require().NoError(err) + + suite.referenceImages = images + + for _, img := range suite.referenceImages { + suite.Require().NoError(s.AddImage(img)) + } + + suite.Require().Nil(err) + suite.testServer = s +} + +func (suite *ImageLockTestSuite) TestGenerateFromChart() { + t := suite.T() + sb := suite.sb + require := suite.Require() + assert := suite.Assert() + + chartName := "wordpress" + chartVersion := "1.0.0" + appVersion := "6.7.8" + serverURL := suite.testServer.ServerURL + + sampleImages, err := suite.testServer.LoadImagesFromFile("../../testdata/images.json") + suite.Require().NoError(err) + + t.Run("Loads from Helm chart", func(_ *testing.T) { + + scenarioName := "custom-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + referenceLock := NewImagesLock() + + referenceLock.Chart.Name = chartName + referenceLock.Chart.Version = chartVersion + referenceLock.Chart.AppVersion = appVersion + + imgs, err := suite.getCustomizedReferenceImages(referenceLock.Chart.Name, + "wordpress", "bitnami-shell", "apache-exporter") + require.NoError(err) + + referenceLock.Images = imgs + + chartRoot := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartRoot, + map[string]interface{}{ + "ServerURL": serverURL, "Images": imgs, "Name": chartName, "Version": chartVersion, "AppVersion": appVersion, + }, + )) + + lock, err := GenerateFromChart(chartRoot, Insecure) + assert.Nil(err, "failed to create Images.lock from Helm chart: %v", err) + assert.NotNil(lock) + // Not interested on this for the comparison + lock.Metadata["generatedAt"] = "" + referenceLock.Metadata["generatedAt"] = "" + + assert.Equal(referenceLock, lock) + }) + t.Run("Loads Helm chart with dependencies", func(_ *testing.T) { + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario("../../testdata/scenarios/chart1", chartDir, + map[string]interface{}{"ServerURL": serverURL}, + )) + + lock, err := GenerateFromChart(chartDir, Insecure) + assert.NoError(err, "failed to create Images.lock from Helm chart: %v", err) + require.NotNil(lock) + // Not interested on this for the comparison + lock.Metadata["generatedAt"] = "" + + existingLock, err := FromYAMLFile(filepath.Join(chartDir, "Images.lock")) + assert.NoError(err) + // Not interested on this for the comparison + existingLock.Metadata["generatedAt"] = "" + assert.Equal(existingLock, lock) + }) + + t.Run("Retrieves only the specified platforms", func(_ *testing.T) { + scenarioName := "custom-chart" + chartName := "test" + + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": sampleImages, "Name": chartName, "RepositoryURL": serverURL}, + )) + + platforms := []string{"linux/amd64"} + lock, err := GenerateFromChart(chartDir, Insecure, WithPlatforms(platforms)) + require.NoError(err, "failed to create Images.lock from Helm chart: %v", err) + // Not interested on this for the comparison + assert.Len(lock.Images, len(sampleImages)) + for _, img := range lock.Images { + assert.Len(img.Digests, len(platforms)) + // To ensure this will fail if we ever change the list of architectures + assert.Len(img.Digests, 1) + assert.Equal(img.Digests[0].Arch, platforms[0]) + } + }) + t.Run("Lock from single arch images", func(t *testing.T) { + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + serverURL := u.Host + images := []*tu.ImageData{ + { + Name: "app1", + Image: fmt.Sprintf("%s/bitnami/app1:latest", serverURL), + }, + } + for _, img := range images { + craneImg, err := tu.CreateSingleArchImage(img, "linux/amd64") + require.NoError(err) + require.NoError(crane.Push(craneImg, img.Image, crane.Insecure)) + } + scenarioName := "custom-chart" + chartName := "test" + chartVersion := "1.0.0" + appVersion := "2.2.0" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{"ServerURL": serverURL, "Images": images, "Name": chartName, "Version": chartVersion, "AppVersion": appVersion}, + )) + + expectedLock := createLockFromImageData(map[string][]*tu.ImageData{ + chartName: images, + }) + expectedLock.Chart.Name = chartName + expectedLock.Chart.Version = chartVersion + expectedLock.Chart.AppVersion = appVersion + expectedLock.Metadata["generatedAt"] = "" + + lock, err := GenerateFromChart(chartDir, WithInsecure(true)) + require.NoError(err, "failed to create Images.lock from Helm chart: %v", err) + + // Not interested on this for the comparison + lock.Metadata["generatedAt"] = "" + + assert.Equal(expectedLock, lock) + }) + + t.Run("Gracefully fails when loading images without platform", func(t *testing.T) { + silentLog := log.New(io.Discard, "", 0) + s := httptest.NewServer(registry.New(registry.Logger(silentLog))) + defer s.Close() + + u, err := url.Parse(s.URL) + if err != nil { + t.Fatal(err) + } + serverURL := u.Host + + craneImg, err := crane.Image(map[string][]byte{ + "platform.txt": []byte("undefined"), + }) + require.NoError(err) + + image := fmt.Sprintf("%s/bitnami/app1:latest", serverURL) + + require.NoError(crane.Push(craneImg, image, crane.Insecure)) + + scenarioName := "custom-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartDir, + map[string]interface{}{ + "ServerURL": serverURL, + "Images": []*tu.ImageData{ + { + Name: "app1", + Image: image, + }, + }, + "Name": "test", + "Version": "1.0.0"}, + )) + + _, err = GenerateFromChart(chartDir, Insecure) + assert.ErrorContains(err, "failed to obtain image platform") + }) + + t.Run("Fails when no archs are retrieved", func(_ *testing.T) { + chartDir := sb.TempFile() + + require.NoError(tu.RenderScenario("../../testdata/scenarios/chart1", chartDir, + map[string]interface{}{"ServerURL": serverURL}, + )) + + lock, err := GenerateFromChart(chartDir, Insecure, WithPlatforms([]string{"invalid"})) + assert.ErrorContains(err, "got empty list of digests after applying platforms filter") + require.Nil(lock) + }) + + t.Run("Fails when missing Helm chart dependencies", func(_ *testing.T) { + type chartDependency struct { + Name string + Repository string + Version string + } + + scenarioName := "custom-chart" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + + chartRoot := sb.TempFile() + + require.NoError(tu.RenderScenario(scenarioDir, chartRoot, map[string]interface{}{"ServerURL": serverURL, + "Dependencies": []chartDependency{{ + Name: "wordpress", Version: "1.0.0", + Repository: "oci://registry-1.docker.io/bitnamicharts", + }}, + "Name": chartName, "Version": chartVersion, + })) + + _, err := GenerateFromChart(chartRoot, Insecure) + assert.ErrorContains(err, "the Helm chart defines dependencies but they are not present in the charts directory") + }) + + t.Run("Fails to load from invalid directory", func(_ *testing.T) { + chartRoot := sb.TempFile() + require.NoFileExists(chartRoot) + + _, err := GenerateFromChart(chartRoot, Insecure) + assert.ErrorContains(err, "no such file or directory") + }) +} + +func TestImageLockTestSuite(t *testing.T) { + suite.Run(t, new(ImageLockTestSuite)) +} + +func (suite *ImageLockTestSuite) TestFindImageByName() { + t := suite.T() + il := NewImagesLock() + imgs, err := suite.getCustomizedReferenceImages("sample", + "wordpress", "bitnami-shell", "apache-exporter") + + require.NoError(t, err) + il.Images = imgs + + // All images are found + t.Run("All images are found", func(t *testing.T) { + for _, img := range imgs { + foundImg, err := il.FindImageByName("sample", img.Name) + assert.NoError(t, err) + assert.Equal(t, img, foundImg) + } + }) + t.Run("Image is not found for different Helm chart", func(t *testing.T) { + for _, img := range imgs { + foundImg, err := il.FindImageByName("invalid_chart", img.Name) + assert.Nil(t, foundImg) + assert.ErrorContains(t, err, "cannot find image") + } + }) + +} +func (suite *ImageLockTestSuite) TestValidate() { + t := suite.T() + il := NewImagesLock() + imgs, err := suite.getCustomizedReferenceImages("sample", + "wordpress", "bitnami-shell", "apache-exporter") + + require.NoError(t, err) + il.Images = imgs + + cloneImages := func(imgs []*ChartImage) []*ChartImage { + newImgs := make([]*ChartImage, 0) + for _, img := range imgs { + // copy the struct + newImg := *img + newImg.Digests = make([]DigestInfo, len(img.Digests)) + copy(newImg.Digests, img.Digests) + newImgs = append(newImgs, &newImg) + } + return newImgs + } + t.Run("Properly Validates Without Changes", func(t *testing.T) { + newImgs := cloneImages(imgs) + + assert.NoError(t, il.Validate(newImgs)) + }) + t.Run("Fails to Validate when missing image", func(t *testing.T) { + newImgs := cloneImages(imgs) + newImgs = append(newImgs, &ChartImage{ + Chart: "dummy", + Name: "dummy_image", + }) + assert.ErrorContains(t, il.Validate(newImgs), `chart "dummy": cannot find image "dummy_image"`) + }) + t.Run("Fails to Validate when changed digest", func(t *testing.T) { + newImgs := cloneImages(imgs) + newImgs[0].Digests[0].Digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + assert.ErrorContains(t, il.Validate(newImgs), `digests do not match`) + }) + t.Run("Fails to Validate when missing arch digest", func(t *testing.T) { + newImgs := cloneImages(imgs) + newImgs[0].Digests = append(newImgs[0].Digests, DigestInfo{Arch: "windows/arm64"}) + assert.ErrorContains(t, il.Validate(newImgs), `failed to find digest for arch "windows/arm64"`) + }) +} + +func (suite *ImageLockTestSuite) TestYAML() { + t := suite.T() + il := NewImagesLock() + il.Chart.Name = "test" + il.Chart.Version = "1.0.0" + il.Images = []*ChartImage{ + { + Name: "test", + Chart: "test", + Digests: []DigestInfo{ + { + Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + Arch: "linux/amd64", + }, + }, + }, + } + + expected := fmt.Sprintf(`apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "%s" + generatedBy: Distribution Tooling for Helm +chart: + name: test + version: 1.0.0 + appVersion: "" +images: + - name: test + image: "" + chart: test + digests: + - digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + arch: linux/amd64 +`, il.Metadata["generatedAt"]) + + t.Run("ToYAML", func(t *testing.T) { + t.Run("Serializes to YAML", func(t *testing.T) { + buff := &bytes.Buffer{} + err := il.ToYAML(buff) + assert.NoError(t, err) + assert.Equal(t, expected, buff.String()) + }) + }) + t.Run("FromYAML", func(t *testing.T) { + t.Run("Deserializes from YAML", func(t *testing.T) { + buff := bytes.NewBufferString(expected) + newLock, err := FromYAML(buff) + assert.NoError(t, err) + assert.True(t, reflect.DeepEqual(newLock, il), "read lock does not match") + assert.Equal(t, il, newLock) + }) + t.Run("Fails on invalid YAML", func(t *testing.T) { + buff := bytes.NewBufferString(`this is invalid`) + _, err := FromYAML(buff) + assert.ErrorContains(t, err, "failed to load") + }) + }) + t.Run("FromYAMLFile", func(t *testing.T) { + sb := suite.sb + require := suite.Require() + + assert := suite.Assert() + + t.Run("Deserializes from YAML File", func(_ *testing.T) { + f := sb.TempFile() + require.NoError(os.WriteFile(f, []byte(expected), 0644)) + newLock, err := FromYAMLFile(f) + assert.NoError(err) + assert.Equal(il, newLock) + }) + + t.Run("Fails on invalid YAML file", func(_ *testing.T) { + nonExisting := sb.TempFile() + require.NoFileExists(nonExisting) + _, err := FromYAMLFile(nonExisting) + assert.ErrorContains(err, "no such file or directory") + }) + }) +} diff --git a/pkg/imagelock/options.go b/pkg/imagelock/options.go new file mode 100644 index 0000000..695ac83 --- /dev/null +++ b/pkg/imagelock/options.go @@ -0,0 +1,79 @@ +package imagelock + +import ( + "context" +) + +// Auth defines the authentication information to access the container registry +type Auth struct { + Username string + Password string +} + +// Config defines configuration options for ImageLock functions +type Config struct { + InsecureMode bool + AnnotationsKey string + Context context.Context + Auth Auth + Platforms []string +} + +// NewImagesLockConfig returns a new ImageLockConfig with default values +func NewImagesLockConfig(opts ...Option) *Config { + cfg := &Config{ + AnnotationsKey: DefaultAnnotationsKey, + Context: context.Background(), + Platforms: make([]string, 0), + } + + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// Option defines a ImageLockConfig option +type Option func(*Config) + +// Insecure asks the tool to allow insecure HTTPS connections to the remote server. +func Insecure(ic *Config) { + ic.InsecureMode = true +} + +// WithAuth provides authentication information to access the container registry +func WithAuth(username, password string) func(ic *Config) { + return func(ic *Config) { + ic.Auth.Username = username + ic.Auth.Password = password + } +} + +// WithPlatforms configures the Platforms of the Config +func WithPlatforms(platforms []string) func(ic *Config) { + return func(ic *Config) { + ic.Platforms = platforms + } +} + +// WithInsecure configures the InsecureMode of the Config +func WithInsecure(insecure bool) func(ic *Config) { + return func(ic *Config) { + ic.InsecureMode = insecure + } +} + +// WithContext provides an execution context +func WithContext(ctx context.Context) func(ic *Config) { + return func(ic *Config) { + ic.Context = ctx + } +} + +// WithAnnotationsKey provides a custom annotation key to use when +// reading/writing the list of images +func WithAnnotationsKey(str string) func(ic *Config) { + return func(ic *Config) { + ic.AnnotationsKey = str + } +} diff --git a/pkg/log/logger.go b/pkg/log/logger.go new file mode 100644 index 0000000..6f15e7c --- /dev/null +++ b/pkg/log/logger.go @@ -0,0 +1,66 @@ +// Package log defines the Logger interfaces +package log + +import ( + "io" + + "github.com/sirupsen/logrus" +) + +// Level defines a type for log levels +type Level logrus.Level + +const ( + // PanicLevel level, highest level of severity. Logs and then calls panic with the + // message passed to Debug, Info, ... + PanicLevel = Level(logrus.PanicLevel) + // FatalLevel level. Logs and then calls `logger.Exit(1)`. It will exit even if the + // logging level is set to Panic. + FatalLevel = Level(logrus.FatalLevel) + // ErrorLevel level. Logs. Used for errors that should definitely be noted. + // Commonly used for hooks to send errors to an error tracking service. + ErrorLevel = Level(logrus.ErrorLevel) + // WarnLevel level. Non-critical entries that deserve eyes. + WarnLevel = Level(logrus.WarnLevel) + // InfoLevel level. General operational entries about what's going on inside the + // application. + InfoLevel = Level(logrus.InfoLevel) + // DebugLevel level. Usually only enabled when debugging. Very verbose logging. + DebugLevel = Level(logrus.DebugLevel) + // TraceLevel level. Designates finer-grained informational events than the Debug. + TraceLevel = Level(logrus.TraceLevel) +) + +const ( + // AlwaysLevel is a level to indicate we want to always log + AlwaysLevel = Level(0) +) + +// LoggedError indicates an error that has been already logged +type LoggedError struct { + Err error +} + +// Error returns the wrapped error +func (e *LoggedError) Error() string { return e.Err.Error() } + +// Unwrap returns the wrapped error +func (e *LoggedError) Unwrap() error { return e.Err } + +// ParseLevel returns a Level from its string representation +func ParseLevel(level string) (Level, error) { + l, err := logrus.ParseLevel(level) + return Level(l), err +} + +// Logger defines a common interface for loggers +type Logger interface { + Infof(format string, args ...interface{}) + Errorf(format string, args ...interface{}) + Debugf(format string, args ...interface{}) + Warnf(format string, args ...interface{}) + Printf(format string, args ...interface{}) + SetWriter(w io.Writer) + SetLevel(level Level) + Failf(format string, args ...interface{}) error +} diff --git a/pkg/log/logrus/logger.go b/pkg/log/logrus/logger.go new file mode 100644 index 0000000..760b1f3 --- /dev/null +++ b/pkg/log/logrus/logger.go @@ -0,0 +1,42 @@ +// Package logrus provides a logger implementation using the logrus library +package logrus + +import ( + "fmt" + "io" + + "github.com/sirupsen/logrus" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" +) + +// Logger defines a Logger implemented by logrus +type Logger struct { + *logrus.Logger +} + +// Failf logs a formatted error and returns it back +func (l *Logger) Failf(format string, args ...interface{}) error { + err := fmt.Errorf(format, args...) + l.Errorf("%v", err) + return &log.LoggedError{Err: err} +} + +// SetLevel sets the log level +func (l *Logger) SetLevel(level log.Level) { + l.Logger.SetLevel(logrus.Level(level)) +} + +// SetWriter sets the internal writer used by the log +func (l *Logger) SetWriter(w io.Writer) { + l.Logger.SetOutput(w) +} + +// Printf prints a message in the log +func (l *Logger) Printf(format string, args ...interface{}) { + l.Infof(format, args...) +} + +// NewLogger returns a Logger implemented by logrus +func NewLogger() *Logger { + return &Logger{Logger: logrus.New()} +} diff --git a/pkg/log/logrus/section_logger.go b/pkg/log/logrus/section_logger.go new file mode 100644 index 0000000..9ee6581 --- /dev/null +++ b/pkg/log/logrus/section_logger.go @@ -0,0 +1,45 @@ +package logrus + +import "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + +// SectionLogger defines a SectionLogger implemented by logrus +type SectionLogger struct { + *Logger +} + +// ExecuteStep executes a function while showing an indeterminate progress animation +func (l *SectionLogger) ExecuteStep(title string, fn func() error) error { + l.Info(title) + return fn() +} + +// PrefixText returns the indented version of the provided text +func (l *SectionLogger) PrefixText(txt string) string { + return txt +} + +// StartSection starts a new log section +func (l *SectionLogger) StartSection(string) log.SectionLogger { + return l +} + +// ProgressBar returns a new silent progress bar +func (l *SectionLogger) ProgressBar() log.ProgressBar { + return log.NewLoggedProgressBar(l.Logger) +} + +// Successf logs a new success message (more efusive than Infof) +func (l *SectionLogger) Successf(format string, args ...interface{}) { + l.Infof(format, args...) +} + +// Section executes the provided function inside a new section +func (l *SectionLogger) Section(title string, fn func(log.SectionLogger) error) error { + l.Infof(title) + return fn(l) +} + +// NewSectionLogger returns a new SectionLogger implemented by logrus +func NewSectionLogger() *SectionLogger { + return &SectionLogger{NewLogger()} +} diff --git a/pkg/log/progress.go b/pkg/log/progress.go new file mode 100644 index 0000000..664a5f8 --- /dev/null +++ b/pkg/log/progress.go @@ -0,0 +1,64 @@ +package log + +// LoggedProgressBar defines a widget that supports the ProgressBar interface but just logs messages +type LoggedProgressBar struct { + Logger + totalSteps int + currentSteps int +} + +// NewLoggedProgressBar returns a progress bar that just log messages +func NewLoggedProgressBar(l Logger) *LoggedProgressBar { + return &LoggedProgressBar{Logger: l} +} + +// Stop stops the progress bar +func (p *LoggedProgressBar) Stop() { +} + +// Start initiates the progress bar +func (p *LoggedProgressBar) Start(...interface{}) (ProgressBar, error) { + return p, nil +} + +// WithTotal sets the progress bar total steps +func (p *LoggedProgressBar) WithTotal(steps int) ProgressBar { + p.totalSteps = steps + return p +} + +// Error shows an error message +func (p *LoggedProgressBar) Error(fmt string, args ...interface{}) { + p.Errorf(fmt, args...) +} + +// Info shows an info message +func (p *LoggedProgressBar) Info(fmt string, args ...interface{}) { + p.Infof(fmt, args...) +} + +// Successf displays a success message +func (p *LoggedProgressBar) Successf(fmt string, args ...interface{}) { + p.Infof(fmt, args...) +} + +// Warning displays a warning message +func (p *LoggedProgressBar) Warning(fmt string, args ...interface{}) { + p.Warnf(fmt, args...) +} + +// UpdateTitle updates the progress bar title +func (p *LoggedProgressBar) UpdateTitle(str string) ProgressBar { + p.Infof("[ %3d/%3d ] %s", p.currentSteps, p.totalSteps, str) + return p +} + +// Add increments the progress bar the specified amount +func (p *LoggedProgressBar) Add(steps int) ProgressBar { + newSteps := p.currentSteps + steps + if newSteps > p.totalSteps { + newSteps = p.totalSteps + } + p.currentSteps = newSteps + return p +} diff --git a/pkg/log/pterm/logger.go b/pkg/log/pterm/logger.go new file mode 100644 index 0000000..0669945 --- /dev/null +++ b/pkg/log/pterm/logger.go @@ -0,0 +1,72 @@ +// Package pterm provides a logger implementation using the pterm library +package pterm + +import ( + "fmt" + "io" + "os" + + "github.com/pterm/pterm" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" +) + +// NewLogger returns a new Logger implemented by pterm +func NewLogger() *Logger { + return &Logger{writer: os.Stdout, level: log.InfoLevel} +} + +// Logger defines a logger implemented using pterm +type Logger struct { + writer io.Writer + level log.Level + prefix string +} + +func (l *Logger) printMessage(messageLevel log.Level, printer *pterm.PrefixPrinter, format string, args ...interface{}) { + if messageLevel > l.level { + return + } + pterm.Fprintln(l.writer, l.prefix+printer.Sprint(fmt.Sprintf(format, args...))) +} + +// SetWriter sets the internal writer used by the log +func (l *Logger) SetWriter(w io.Writer) { + l.writer = w +} + +// SetLevel sets the log level +func (l *Logger) SetLevel(level log.Level) { + l.level = level +} + +// Failf logs a formatted error and returns it back +func (l *Logger) Failf(format string, args ...interface{}) error { + err := fmt.Errorf(format, args...) + l.Errorf("%v", err) + return &log.LoggedError{Err: err} +} + +// Printf prints a message in the log +func (l *Logger) Printf(format string, args ...interface{}) { + l.printMessage(log.AlwaysLevel, Plain, format, args...) +} + +// Errorf logs an error message +func (l *Logger) Errorf(format string, args ...interface{}) { + l.printMessage(log.ErrorLevel, Error, format, args...) +} + +// Infof logs an information message +func (l *Logger) Infof(format string, args ...interface{}) { + l.printMessage(log.InfoLevel, Info, format, args...) +} + +// Debugf logs a debug message +func (l *Logger) Debugf(format string, args ...interface{}) { + l.printMessage(log.DebugLevel, Debug, format, args...) +} + +// Warnf logs a warning message +func (l *Logger) Warnf(format string, args ...interface{}) { + l.printMessage(log.WarnLevel, Warning, format, args...) +} diff --git a/pkg/log/pterm/printers.go b/pkg/log/pterm/printers.go new file mode 100644 index 0000000..b50f4ef --- /dev/null +++ b/pkg/log/pterm/printers.go @@ -0,0 +1,43 @@ +package pterm + +import "github.com/pterm/pterm" + +var ( + // Fold defines a printer that prefixes text by a 'fold' symbol + Fold = pterm.Info.WithMessageStyle(&pterm.ThemeDefault.InfoMessageStyle).WithPrefix(pterm.Prefix{ + Style: pterm.NewStyle(pterm.FgBlue), + Text: "\u00BB", // "ยป" + }) + // Plain defines a printer with empty prefix + Plain = pterm.Info.WithMessageStyle(&pterm.ThemeDefault.InfoMessageStyle).WithPrefix(pterm.Prefix{ + Style: pterm.NewStyle(pterm.FgBlue), + Text: " ", + }) + // Error defines a printer for errors + Error = pterm.Error.WithMessageStyle(&pterm.ThemeDefault.ErrorMessageStyle).WithPrefix(pterm.Prefix{ + Style: pterm.NewStyle(pterm.FgRed), + Text: "\u2718", // "โœ˜" + }) + // Warning defines a printer for warnings + Warning = pterm.Warning.WithMessageStyle(&pterm.ThemeDefault.WarningMessageStyle).WithPrefix(pterm.Prefix{ + Style: pterm.NewStyle(pterm.FgYellow), + Text: "\u26A0\uFE0F", + }) + // Debug defines a printer for debug messages + Debug = pterm.Debug.WithMessageStyle(&pterm.ThemeDefault.DebugMessageStyle).WithDebugger(false).WithPrefix(pterm.Prefix{ + Style: pterm.NewStyle(pterm.FgGray), + Text: "\U0001F50D", + }) + // Info defines a printer for info messages + Info = pterm.Success.WithMessageStyle(&pterm.ThemeDefault.SuccessMessageStyle).WithPrefix(pterm.Prefix{ + Style: pterm.NewStyle(pterm.FgLightGreen), + Text: "\u2714", // "โœ”" + }) + // Success defines a printer for success messages + Success = pterm.Success.WithMessageStyle(&pterm.ThemeDefault.SuccessMessageStyle).WithPrefix(pterm.Prefix{ + Style: pterm.NewStyle(pterm.FgLightGreen), + // This is a rocket + // Text: "\U0001F680", + Text: "\U0001F389", + }) +) diff --git a/pkg/log/pterm/progress.go b/pkg/log/pterm/progress.go new file mode 100644 index 0000000..02534fd --- /dev/null +++ b/pkg/log/pterm/progress.go @@ -0,0 +1,91 @@ +package pterm + +import ( + "fmt" + + "github.com/pterm/pterm" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +// ProgressBar defines a progress bar with fancy cui effects +type ProgressBar struct { + *pterm.ProgressbarPrinter + padding string +} + +func (p *ProgressBar) printMessage(printer *pterm.PrefixPrinter, format string, args ...interface{}) { + pterm.Fprintln(printer.Writer, p.padding+printer.Sprint(fmt.Sprintf(format, args...))) +} + +// Stop stops the progress bar +func (p *ProgressBar) Stop() { + _, _ = p.ProgressbarPrinter.Stop() +} + +// Start initiates the progress bar +func (p *ProgressBar) Start(title ...interface{}) (log.ProgressBar, error) { + res, err := p.ProgressbarPrinter.Start(title...) + if err != nil { + return p, fmt.Errorf("failed to start progress bar: %w", err) + } + p.ProgressbarPrinter = res + return p, nil +} + +// WithTotal sets the progress bar total steps +func (p *ProgressBar) WithTotal(n int) log.ProgressBar { + p.ProgressbarPrinter = p.ProgressbarPrinter.WithTotal(n) + return p +} + +// Errorf shows an error message +func (p *ProgressBar) Errorf(fmt string, args ...interface{}) { + p.printMessage(Error, fmt, args...) +} + +// Infof shows an info message +func (p *ProgressBar) Infof(fmt string, args ...interface{}) { + p.printMessage(Info, fmt, args...) +} + +// Successf displays a success message +func (p *ProgressBar) Successf(fmt string, args ...interface{}) { + p.printMessage(Success, fmt, args...) +} + +// Warnf displays a warning message +func (p *ProgressBar) Warnf(fmt string, args ...interface{}) { + p.printMessage(&pterm.Warning, fmt, args...) +} + +func (p *ProgressBar) formatTitle(title string) string { + // We prefix with a leading " " so we align with other printers, that + // start with a leading space + paddedTitle := " " + p.padding + title + maxTitleLength := int(float32(p.ProgressbarPrinter.MaxWidth) * 0.70) + truncatedTitle := utils.TruncateStringWithEllipsis(paddedTitle, maxTitleLength) + return fmt.Sprintf("%-*s", maxTitleLength, truncatedTitle) +} + +// UpdateTitle updates the progress bar title +func (p *ProgressBar) UpdateTitle(title string) log.ProgressBar { + p.ProgressbarPrinter.UpdateTitle(p.formatTitle(title)) + return p +} + +// Add increments the progress bar the specified amount +func (p *ProgressBar) Add(inc int) log.ProgressBar { + p.ProgressbarPrinter.Add(inc) + return p +} + +// NewProgressBar returns a new NewProgressBar +func NewProgressBar(padding string) *ProgressBar { + p := pterm.DefaultProgressbar.WithMaxWidth(pterm.GetTerminalWidth()).WithRemoveWhenDone(true) + + return &ProgressBar{ + ProgressbarPrinter: p, + padding: padding, + } +} diff --git a/pkg/log/pterm/section_logger.go b/pkg/log/pterm/section_logger.go new file mode 100644 index 0000000..989d27e --- /dev/null +++ b/pkg/log/pterm/section_logger.go @@ -0,0 +1,77 @@ +package pterm + +import ( + "fmt" + "strings" + + "github.com/vmware-labs/distribution-tooling-for-helm/internal/widgets" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" +) + +const ( + // Number of spaces for each nesting level + nestSpacing = 3 +) + +// NewSectionLogger returns a new SectionLogger implemented by pterm +func NewSectionLogger() *SectionLogger { + return &SectionLogger{Logger: NewLogger()} +} + +// SectionLogger defines a SectionLogger using pterm +type SectionLogger struct { + *Logger + nestLevel int +} + +// ProgressBar returns a new ProgressBar +func (l *SectionLogger) ProgressBar() log.ProgressBar { + return NewProgressBar(l.prefix) +} + +// Successf logs a new success message (more efusive than Infof) +func (l *SectionLogger) Successf(format string, args ...interface{}) { + l.printMessage(log.InfoLevel, Success, format, args...) +} + +// PrefixText returns the indented version of the provided text +func (l *SectionLogger) PrefixText(txt string) string { + // We include a leading " " as this is intended to align with our printers, + // and all printers do " " + printer.Text + " " + Text + // so the extra space aligns the txt with the printer.Text char + lines := make([]string, 0) + for _, line := range strings.Split(txt, "\n") { + lines = append(lines, fmt.Sprintf(" %s%s", l.prefix, line)) + } + return strings.Join(lines, "\n") +} + +// ExecuteStep executes a function while showing an indeterminate progress animation +func (l *SectionLogger) ExecuteStep(title string, fn func() error) error { + err := widgets.ExecuteWithSpinner( + widgets.DefaultSpinner.WithPrefix(l.prefix), + title, + func() error { + return fn() + }, + ) + return err +} + +// Section executes the provided function inside a new section +func (l *SectionLogger) Section(title string, fn func(log.SectionLogger) error) error { + childLog := l.StartSection(title) + return fn(childLog) +} + +// StartSection starts a new log section, with nested indentation +func (l *SectionLogger) StartSection(str string) log.SectionLogger { + l.printMessage(log.AlwaysLevel, Fold, str) + return l.nest() +} +func (l *SectionLogger) nest() log.SectionLogger { + newLog := &SectionLogger{nestLevel: l.nestLevel + 1, Logger: NewLogger()} + newLog.prefix = strings.Repeat(" ", newLog.nestLevel*nestSpacing) + newLog.level = l.level + return newLog +} diff --git a/pkg/log/section_logger.go b/pkg/log/section_logger.go new file mode 100644 index 0000000..665c198 --- /dev/null +++ b/pkg/log/section_logger.go @@ -0,0 +1,27 @@ +package log + +// ProgressBar defines a ProgressBar widget +type ProgressBar interface { + WithTotal(total int) ProgressBar + UpdateTitle(title string) ProgressBar + Add(increment int) ProgressBar + Start(title ...interface{}) (ProgressBar, error) + Stop() + Successf(fmt string, args ...interface{}) + Errorf(fmt string, args ...interface{}) + Infof(fmt string, args ...interface{}) + Warnf(fmt string, args ...interface{}) +} + +// SectionLogger defines an interface for loggers supporting nested levels of loggin +type SectionLogger interface { + Logger + Successf(format string, args ...interface{}) + PrefixText(string) string + StartSection(title string) SectionLogger + // Nest(title string) SectionLogger + // NestLevel() int + Section(title string, fn func(SectionLogger) error) error + ExecuteStep(title string, fn func() error) error + ProgressBar() ProgressBar +} diff --git a/pkg/log/silent/logger.go b/pkg/log/silent/logger.go new file mode 100644 index 0000000..3066a6a --- /dev/null +++ b/pkg/log/silent/logger.go @@ -0,0 +1,43 @@ +package silent + +import ( + "fmt" + "io" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" +) + +// Logger defines a logger that does not log anything +type Logger struct { +} + +// NewLogger returns a new Logger that does not log any message +func NewLogger() *Logger { + return &Logger{} +} + +// Infof logs nothing +func (l *Logger) Infof(string, ...interface{}) {} + +// Errorf logs nothing +func (l *Logger) Errorf(string, ...interface{}) {} + +// Debugf logs nothing +func (l *Logger) Debugf(string, ...interface{}) {} + +// Warnf logs nothing +func (l *Logger) Warnf(string, ...interface{}) {} + +// Printf logs nothing +func (l *Logger) Printf(string, ...interface{}) {} + +// SetWriter does nothing +func (l *Logger) SetWriter(io.Writer) {} + +// SetLevel does nothing +func (l *Logger) SetLevel(log.Level) {} + +// Failf returns a LoggedError +func (l *Logger) Failf(format string, args ...interface{}) error { + return &log.LoggedError{Err: fmt.Errorf(format, args...)} +} diff --git a/pkg/log/silent/progress.go b/pkg/log/silent/progress.go new file mode 100644 index 0000000..ac20d9b --- /dev/null +++ b/pkg/log/silent/progress.go @@ -0,0 +1,53 @@ +package silent + +import "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + +// ProgressBar defines a widget that supports the ProgressBar interface and does nothing +type ProgressBar struct { +} + +// NewProgressBar returns a new ProgressBar that does not produce any output +func NewProgressBar() *ProgressBar { + return &ProgressBar{} +} + +// Stop stops the progress bar +func (p *ProgressBar) Stop() { +} + +// Start initiates the progress bar +func (p *ProgressBar) Start(...interface{}) (log.ProgressBar, error) { + return p, nil +} + +// WithTotal sets the progress bar total steps +func (p *ProgressBar) WithTotal(int) log.ProgressBar { + return p +} + +// Errorf shows an error message +func (p *ProgressBar) Errorf(string, ...interface{}) { + +} + +// Infof shows an info message +func (p *ProgressBar) Infof(string, ...interface{}) { +} + +// Successf displays a success message +func (p *ProgressBar) Successf(string, ...interface{}) { +} + +// Warnf displays a warning message +func (p *ProgressBar) Warnf(string, ...interface{}) { +} + +// UpdateTitle updates the progress bar title +func (p *ProgressBar) UpdateTitle(string) log.ProgressBar { + return p +} + +// Add increments the progress bar the specified amount +func (p *ProgressBar) Add(int) log.ProgressBar { + return p +} diff --git a/pkg/log/silent/section_logger.go b/pkg/log/silent/section_logger.go new file mode 100644 index 0000000..50a73be --- /dev/null +++ b/pkg/log/silent/section_logger.go @@ -0,0 +1,45 @@ +// Package silent implements a silent logger +package silent + +import ( + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" +) + +// SectionLogger is a SectionLogger that does not output anything +type SectionLogger struct { + *Logger +} + +// NewSectionLogger creates a new SilentSectionLogger +func NewSectionLogger() *SectionLogger { + return &SectionLogger{&Logger{}} +} + +// ExecuteStep executes a function while showing an indeterminate progress animation +func (l *SectionLogger) ExecuteStep(_ string, fn func() error) error { + return fn() +} + +// PrefixText returns the indented version of the provided text +func (l *SectionLogger) PrefixText(txt string) string { + return txt +} + +// StartSection starts a new log section +func (l *SectionLogger) StartSection(string) log.SectionLogger { + return l +} + +// ProgressBar returns a new silent progress bar +func (l *SectionLogger) ProgressBar() log.ProgressBar { + return NewProgressBar() +} + +// Successf logs a new success message (more efusive than Infof) +func (l *SectionLogger) Successf(string, ...interface{}) { +} + +// Section executes the provided function inside a new section +func (l *SectionLogger) Section(_ string, fn func(log.SectionLogger) error) error { + return fn(l) +} diff --git a/pkg/relocator/annotations.go b/pkg/relocator/annotations.go new file mode 100644 index 0000000..9e9e418 --- /dev/null +++ b/pkg/relocator/annotations.go @@ -0,0 +1,40 @@ +package relocator + +import ( + "fmt" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + cu "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" +) + +// RelocateAnnotations rewrites the image urls in the chart annotations using the provided prefix +func RelocateAnnotations(chartDir string, prefix string, opts ...chartutils.Option) (string, error) { + c, err := chartutils.LoadChart(chartDir, opts...) + if err != nil { + return "", fmt.Errorf("failed to relocate annotations: %w", err) + } + res, err := relocateAnnotations(c, prefix) + if err != nil { + return "", fmt.Errorf("failed to relocate annotations: %w", err) + } + return string(res.Data), nil +} + +func relocateAnnotations(c *cu.Chart, prefix string) (*RelocationResult, error) { + images, err := c.GetAnnotatedImages() + if err != nil { + return nil, fmt.Errorf("failed to read images from annotations: %v", err) + } + count, err := relocateImages(images, prefix) + if err != nil { + return nil, fmt.Errorf("failed to relocate annotations: %v", err) + } + + data, err := images.ToAnnotation() + if err != nil { + return nil, fmt.Errorf("failed to relocate annotations: %v", err) + } + + result := &RelocationResult{Data: data, Count: count} + return result, nil +} diff --git a/pkg/relocator/annotations_test.go b/pkg/relocator/annotations_test.go new file mode 100644 index 0000000..e80d3ac --- /dev/null +++ b/pkg/relocator/annotations_test.go @@ -0,0 +1,28 @@ +package relocator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" +) + +func TestRelocateAnnotations(t *testing.T) { + chartDir := sb.TempFile() + serverURL := "localhost" + + require.NoError(t, tu.RenderScenario("../../testdata/scenarios/chart1", chartDir, map[string]interface{}{"ServerURL": serverURL})) + + newServerURL := "test.example.com" + expectedAnnotations, err := tu.RenderTemplateFile("../../testdata/scenarios/chart1/images.partial.tmpl", map[string]string{"ServerURL": newServerURL}) + require.NoError(t, err) + + expectedAnnotations = strings.TrimSpace(expectedAnnotations) + + newAnnotations, err := RelocateAnnotations(chartDir, newServerURL) + require.NoError(t, err) + + assert.Equal(t, strings.TrimSpace(expectedAnnotations), strings.TrimSpace(newAnnotations)) +} diff --git a/pkg/relocator/chart.go b/pkg/relocator/chart.go new file mode 100644 index 0000000..78bc170 --- /dev/null +++ b/pkg/relocator/chart.go @@ -0,0 +1,162 @@ +// Package relocator implements the functionality to rewrite image URLs +// in Charts +package relocator + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/carvel" + cu "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + "github.com/vmware-tanzu/carvel-imgpkg/pkg/imgpkg/lockconfig" +) + +// RelocationResult describes the result of performing a relocation +type RelocationResult struct { + // Name is the name of the values file + Name string + // Data is the relocated data + Data []byte + // Count is the number of relocated images + Count int +} + +func relocateChart(chart *cu.Chart, newRegistry string, cfg *RelocateConfig) error { + valuesReplRes, err := relocateValues(chart, newRegistry) + if err != nil { + return fmt.Errorf("failed to relocate chart: %v", err) + } + + for _, result := range valuesReplRes { + if result.Count > 0 { + if err := os.WriteFile(chart.AbsFilePath(result.Name), result.Data, 0644); err != nil { + return fmt.Errorf("failed to write %s: %v", result.Name, err) + } + } + } + + var allErrors error + + // TODO: Compare annotations with values replacements + annotationsRelocResult, err := relocateAnnotations(chart, newRegistry) + if err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to relocate Helm chart: %v", err)) + } else { + if annotationsRelocResult.Count > 0 { + annotationsKeyPath := fmt.Sprintf("$.annotations['%s']", cfg.ImageLockConfig.AnnotationsKey) + if err := utils.YamlFileSet(chart.AbsFilePath("Chart.yaml"), map[string]string{ + annotationsKeyPath: string(annotationsRelocResult.Data), + }); err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to relocate Helm chart: failed to write annotations: %v", err)) + } + } + } + + lockFile := chart.LockFilePath() + if utils.FileExists(lockFile) { + err = RelocateLockFile(lockFile, newRegistry) + if err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to relocate Images.lock file: %v", err)) + } + } + + if cfg.Recursive { + for _, dep := range chart.Dependencies() { + if err := relocateChart(dep, newRegistry, cfg); err != nil { + allErrors = errors.Join(allErrors, fmt.Errorf("failed to relocate Helm SubChart %q: %v", dep.Chart().ChartFullPath(), err)) + } + } + } + + return allErrors +} + +// RelocateChartDir relocates the chart (Chart.yaml annotations, Images.lock and values.yaml) specified +// by chartPath using the provided prefix +func RelocateChartDir(chartPath string, newRegistry string, opts ...RelocateOption) error { + newRegistry = normalizeRelocateURL(newRegistry) + + cfg := NewRelocateConfig(opts...) + + chart, err := cu.LoadChart(chartPath, cu.WithAnnotationsKey(cfg.ImageLockConfig.AnnotationsKey), cu.WithValuesFiles(cfg.ValuesFiles...)) + if err != nil { + return fmt.Errorf("failed to load Helm chart: %v", err) + } + + err = relocateChart(chart, newRegistry, cfg) + if err != nil { + return err + } + if utils.FileExists(filepath.Join(chartPath, carvel.CarvelImagesFilePath)) { + err = relocateCarvelBundle(chartPath, newRegistry) + + if err != nil { + return err + } + } + + return err +} + +func relocateCarvelBundle(chartRoot string, newRegistry string) error { + + //TODO: Do better detection here, imgpkg probably has something + carvelImagesFile := filepath.Join(chartRoot, carvel.CarvelImagesFilePath) + lock, err := lockconfig.NewImagesLockFromPath(carvelImagesFile) + if err != nil { + return fmt.Errorf("failed to load Carvel images lock: %v", err) + } + result, err := RelocateCarvelImagesLock(&lock, newRegistry) + if err != nil { + return err + } + if result.Count == 0 { + return nil + } + if err := utils.SafeWriteFile(carvelImagesFile, result.Data, 0600); err != nil { + return fmt.Errorf("failed to overwrite Carvel images lock file: %v", err) + } + return nil +} + +// RelocateCarvelImagesLock rewrites the images urls in the provided lock using prefix +func RelocateCarvelImagesLock(lock *lockconfig.ImagesLock, newRegistry string) (*RelocationResult, error) { + + count, err := relocateCarvelImages(lock.Images, newRegistry) + if err != nil { + return nil, fmt.Errorf("failed to relocate Carvel images lock file: %v", err) + } + + buff, err := lock.AsBytes() + if err != nil { + return nil, fmt.Errorf("failed to write Images.lock file: %v", err) + } + + return &RelocationResult{Data: buff, Count: count}, nil + +} + +func relocateCarvelImages(images []lockconfig.ImageRef, newRegistry string) (count int, err error) { + var allErrors error + for i, img := range images { + norm, err := utils.RelocateImageURL(img.Image, newRegistry, true) + if err != nil { + allErrors = errors.Join(allErrors, err) + continue + } + images[i].Image = norm + count++ + } + return count, allErrors +} + +func normalizeRelocateURL(url string) string { + ociPrefix := "oci://" + // crane gets confused with the oci schema, so we + // strip it + return strings.TrimPrefix(url, ociPrefix) +} diff --git a/pkg/relocator/chart_test.go b/pkg/relocator/chart_test.go new file mode 100644 index 0000000..721d348 --- /dev/null +++ b/pkg/relocator/chart_test.go @@ -0,0 +1,87 @@ +package relocator + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" + "gopkg.in/yaml.v3" + + "helm.sh/helm/v3/pkg/chart/loader" +) + +func TestRelocateChartDir(t *testing.T) { + scenarioName := "chart1" + scenarioDir := fmt.Sprintf("../../testdata/scenarios/%s", scenarioName) + valuesFiles := []string{"values.yaml", "values.prod.yaml"} + + chartDir := sb.TempFile() + serverURL := "localhost" + + require.NoError(t, tu.RenderScenario(scenarioDir, chartDir, map[string]interface{}{"ServerURL": serverURL})) + + newServerURL := "test.example.com" + repositoryPrefix := "airgap" + fullNewURL := fmt.Sprintf("%s/%s", newServerURL, repositoryPrefix) + + err := RelocateChartDir(chartDir, fullNewURL, WithValuesFiles(valuesFiles...)) + require.NoError(t, err) + + t.Run("Values Relocated", func(t *testing.T) { + for _, valuesFile := range valuesFiles { + t.Logf("checking %s file", valuesFile) + data, err := os.ReadFile(filepath.Join(chartDir, valuesFile)) + require.NoError(t, err) + relocatedValues, err := tu.NormalizeYAML(string(data)) + require.NoError(t, err) + + expectedData, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, fmt.Sprintf("%s.tmpl", valuesFile)), map[string]string{"ServerURL": newServerURL, "RepositoryPrefix": repositoryPrefix}) + require.NoError(t, err) + + expectedValues, err := tu.NormalizeYAML(expectedData) + require.NoError(t, err) + assert.Equal(t, expectedValues, relocatedValues) + } + }) + t.Run("Annotations Relocated", func(t *testing.T) { + c, err := loader.Load(chartDir) + require.NoError(t, err) + + relocatedAnnotations, err := tu.NormalizeYAML(c.Metadata.Annotations["images"]) + require.NoError(t, err) + + require.NotEqual(t, relocatedAnnotations, "") + + expectedData, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "images.partial.tmpl"), map[string]string{"ServerURL": fullNewURL}) + require.NoError(t, err) + + expectedAnnotations, err := tu.NormalizeYAML(expectedData) + require.NoError(t, err) + assert.Equal(t, expectedAnnotations, relocatedAnnotations) + }) + t.Run("ImageLock Relocated", func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(chartDir, "Images.lock")) + assert.NoError(t, err) + var lockData map[string]interface{} + + require.NoError(t, yaml.Unmarshal(data, &lockData)) + + imagesElemData, err := yaml.Marshal(lockData["images"]) + require.NoError(t, err) + + relocatedImagesData, err := tu.NormalizeYAML(string(imagesElemData)) + require.NoError(t, err) + + expectedData, err := tu.RenderTemplateFile(filepath.Join(scenarioDir, "lock_images.partial.tmpl"), map[string]string{"ServerURL": fullNewURL}) + require.NoError(t, err) + expectedData, err = tu.NormalizeYAML(expectedData) + require.NoError(t, err) + + assert.Equal(t, expectedData, relocatedImagesData) + + }) +} diff --git a/pkg/relocator/imagelock.go b/pkg/relocator/imagelock.go new file mode 100644 index 0000000..2b37bb5 --- /dev/null +++ b/pkg/relocator/imagelock.go @@ -0,0 +1,56 @@ +package relocator + +import ( + "bytes" + "errors" + "fmt" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" +) + +func relocateImages(images imagelock.ImageList, newRegistry string) (count int, err error) { + var allErrors error + for _, img := range images { + norm, err := utils.RelocateImageRegistry(img.Image, newRegistry, true) + if err != nil { + allErrors = errors.Join(allErrors, err) + continue + } + img.Image = norm + count++ + } + return count, allErrors +} + +// RelocateLock rewrites the images urls in the provided lock using prefix +func RelocateLock(lock *imagelock.ImagesLock, newRegistry string) (*RelocationResult, error) { + count, err := relocateImages(lock.Images, newRegistry) + if err != nil { + return nil, fmt.Errorf("failed to relocate Images.lock file: %v", err) + } + buff := &bytes.Buffer{} + if err := lock.ToYAML(buff); err != nil { + return nil, fmt.Errorf("failed to write Images.lock file: %v", err) + } + return &RelocationResult{Data: buff.Bytes(), Count: count}, nil +} + +// RelocateLockFile relocates images urls in the provided Images.lock using prefix +func RelocateLockFile(file string, newRegistry string) error { + lock, err := imagelock.FromYAMLFile(file) + if err != nil { + return fmt.Errorf("failed to load Images.lock: %v", err) + } + result, err := RelocateLock(lock, newRegistry) + if err != nil { + return err + } + if result.Count == 0 { + return nil + } + if err := utils.SafeWriteFile(file, result.Data, 0600); err != nil { + return fmt.Errorf("failed to overwrite Images.lock file: %v", err) + } + return nil +} diff --git a/pkg/relocator/options.go b/pkg/relocator/options.go new file mode 100644 index 0000000..a2efce3 --- /dev/null +++ b/pkg/relocator/options.go @@ -0,0 +1,68 @@ +package relocator + +import ( + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log" + silentLog "github.com/vmware-labs/distribution-tooling-for-helm/pkg/log/silent" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" +) + +// RelocateConfig defines the configuration used in the relocator functions +type RelocateConfig struct { + ImageLockConfig imagelock.Config + Log log.Logger + RelocateLockFile bool + Recursive bool + ValuesFiles []string +} + +// NewRelocateConfig returns a new RelocateConfig with default settings +func NewRelocateConfig(opts ...RelocateOption) *RelocateConfig { + cfg := &RelocateConfig{ + Log: silentLog.NewLogger(), + RelocateLockFile: true, + ImageLockConfig: *imagelock.NewImagesLockConfig(), + ValuesFiles: []string{"values.yaml"}, + } + for _, opt := range opts { + opt(cfg) + } + + return cfg +} + +// RelocateOption defines a RelocateConfig option +type RelocateOption func(*RelocateConfig) + +// Recursive asks relocation functions to apply to the chart dependencies recursively +func Recursive(c *RelocateConfig) { + c.Recursive = true +} + +// WithAnnotationsKey customizes the annotations key used in Chart.yaml +func WithAnnotationsKey(str string) func(rc *RelocateConfig) { + return func(rc *RelocateConfig) { + rc.ImageLockConfig.AnnotationsKey = str + } +} + +// WithRelocateLockFile configures the RelocateLockFile configuration +func WithRelocateLockFile(relocateLock bool) func(rc *RelocateConfig) { + return func(rc *RelocateConfig) { + rc.RelocateLockFile = relocateLock + } +} + +// WithLog customizes the log used by the tool +func WithLog(l log.Logger) func(rc *RelocateConfig) { + return func(rc *RelocateConfig) { + rc.Log = l + } +} + +// WithValuesFiles configures the values files to use for relocation +func WithValuesFiles(files ...string) func(rc *RelocateConfig) { + return func(rc *RelocateConfig) { + rc.ValuesFiles = files + } +} diff --git a/pkg/relocator/relocator_test.go b/pkg/relocator/relocator_test.go new file mode 100644 index 0000000..2d0da1a --- /dev/null +++ b/pkg/relocator/relocator_test.go @@ -0,0 +1,25 @@ +package relocator + +import ( + "log" + "os" + "testing" + + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" +) + +var ( + sb *tu.Sandbox +) + +func TestMain(m *testing.M) { + + sb = tu.NewSandbox() + c := m.Run() + + if err := sb.Cleanup(); err != nil { + log.Printf("WARN: failed to cleanup test sandbox: %v", err) + } + + os.Exit(c) +} diff --git a/pkg/relocator/values.go b/pkg/relocator/values.go new file mode 100644 index 0000000..a2cf673 --- /dev/null +++ b/pkg/relocator/values.go @@ -0,0 +1,56 @@ +package relocator + +import ( + "fmt" + + cu "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + + "helm.sh/helm/v3/pkg/chartutil" +) + +func relocateValuesData(valuesFile string, valuesData []byte, prefix string) (*RelocationResult, error) { + valuesMap, err := chartutil.ReadValues(valuesData) + if err != nil { + return nil, fmt.Errorf("failed to parse Helm chart values: %v", err) + } + imageElems, err := cu.FindImageElementsInValuesMap(valuesMap) + if err != nil { + return nil, fmt.Errorf("failed to find Helm chart image elements from values.yaml: %v", err) + } + if len(imageElems) == 0 { + return &RelocationResult{Data: valuesData, Count: 0}, nil + } + + data := make(map[string]string, 0) + for _, e := range imageElems { + err := e.Relocate(prefix) + if err != nil { + return nil, fmt.Errorf("unexpected error relocating: %v", err) + } + for k, v := range e.YamlReplaceMap() { + data[k] = v + } + } + relocatedData, err := utils.YamlSet(valuesData, data) + if err != nil { + return nil, fmt.Errorf("unexpected error relocating: %v", err) + } + return &RelocationResult{Name: valuesFile, Data: relocatedData, Count: len(imageElems)}, nil +} + +func relocateValues(c *cu.Chart, prefix string) ([]*RelocationResult, error) { + result := make([]*RelocationResult, 0, len(c.ValuesFiles())) + for _, values := range c.ValuesFiles() { + if values == nil { + result = append(result, &RelocationResult{}) + continue + } + res, err := relocateValuesData(values.Name, values.Data, prefix) + if err != nil { + return nil, err + } + result = append(result, res) + } + return result, nil +} diff --git a/pkg/utils/copy.go b/pkg/utils/copy.go new file mode 100644 index 0000000..f433b7a --- /dev/null +++ b/pkg/utils/copy.go @@ -0,0 +1,68 @@ +package utils + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +// CopyFile file copies src to dest, preserving permissions +func CopyFile(src, dest string) error { + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat source file: %w", err) + } + return copyFile(src, dest, info.Mode()) +} + +// CopyDir copies the directory src to dest, including its contents +func CopyDir(src, dest string) error { + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + + return filepath.WalkDir(src, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(src, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + destPath := filepath.Join(dest, relPath) + + info, err := entry.Info() + if err != nil { + return fmt.Errorf("failed to get source file info: %w", err) + } + + if entry.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + + return copyFile(path, destPath, info.Mode()) + }) +} + +func copyFile(src, dest string, info fs.FileMode) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, info) + + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return err + } + return nil +} diff --git a/pkg/utils/tar.go b/pkg/utils/tar.go new file mode 100644 index 0000000..a36c925 --- /dev/null +++ b/pkg/utils/tar.go @@ -0,0 +1,272 @@ +package utils + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// MaxDecompressionSize established a high enough maximum tar size to decompres +// to prevent decompression bombs (8GB) +var MaxDecompressionSize int64 = 8 * 1024 * 1024 * 1024 + +// Maybe use this "github.com/mholt/archiver/v4" ? + +func tarFile(tarWriter *tar.Writer, source string, relativePath string, info os.FileInfo) error { + // Create a new tar header for the file + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = relativePath + + err = tarWriter.WriteHeader(header) + if err != nil { + return err + } + // If the file is a regular file, write its contents to the tar + if !info.IsDir() { + file, err := os.Open(source) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(tarWriter, file) + if err != nil { + return err + } + } + return nil +} + +// TarConfig defines the Tar char opts +type TarConfig struct { + Prefix string + StripComponents int + Skip func(f string) bool +} + +// Tar calls TarContext with a Background context +func Tar(sourceDir string, filename string, cfg TarConfig) error { + return TarContext(context.Background(), sourceDir, filename, cfg) +} + +// TarContext compresses the provided sourceDir directory into the .tar.gz specified in filename, +// adding prefix to the added files. +func TarContext(parentCtx context.Context, sourceDir string, filename string, cfg TarConfig) error { + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + prefix := cfg.Prefix + skip := cfg.Skip + if skip == nil { + skip = func(_ string) bool { return false } + } + dir := filepath.Dir(filename) + if !FileExists(dir) { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory %q: %w", dir, err) + } + } + + fh, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create tar.gz filename %q: %w", filename, err) + } + defer fh.Close() + + gzWriter := gzip.NewWriter(fh) + defer gzWriter.Close() + + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + + // Walk through the directory and add files to the tar + err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + select { + case <-ctx.Done(): + return fmt.Errorf("cancelled execution") + default: + if err != nil { + return err + } + trimmedPath := strings.TrimPrefix(path, sourceDir) + if trimmedPath == "" { + // We are considering the sourceDir itself, just skip + return nil + } + + if skip(trimmedPath) { + return nil + } + relPath := filepath.ToSlash(filepath.Join(prefix, trimmedPath)) + + return tarFile(tarWriter, path, relPath, info) + } + }) + return err +} + +func stripPathComponents(filename string, stripComponents int) string { + if stripComponents <= 0 { + return filepath.FromSlash(filename) + } + + elemList := strings.Split(filepath.ToSlash(filename), "/") + if len(elemList) <= stripComponents { + return "" + } + return filepath.FromSlash(filepath.Join(elemList[stripComponents:]...)) +} + +func untarFile(tr *tar.Reader, dest string, header *tar.Header) error { + fi := header.FileInfo() + mode := fi.Mode() + switch { + case mode.IsRegular(): + dir := filepath.Dir(dest) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + wf, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + + n, err := io.CopyN(wf, tr, MaxDecompressionSize) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("error writing to %s: %v", dest, err) + } else if n == MaxDecompressionSize { + return fmt.Errorf("size of decoded data exceeds allowed size %d", MaxDecompressionSize) + } + + if closeErr := wf.Close(); closeErr != nil && err == nil { + err = closeErr + } + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("error writing to %s: %v", dest, err) + } + if n != header.Size { + return fmt.Errorf("only wrote %d bytes to %s; expected %d", n, dest, header.Size) + } + case mode.IsDir(): + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + default: + return fmt.Errorf("tar file entry %s contained unsupported file type %v", header.Name, mode) + } + return nil +} + +// Untar decompresses the provided filename into the outputDir +// Simplified implementation taken from: golang.org/x/build/internal/untar (BSD license) +func Untar(filename string, outputDir string, cfg TarConfig) error { + return UntarContext(context.Background(), filename, outputDir, cfg) +} + +// ErrEndTarWalk allows early stopping inspecting the tar file +var ErrEndTarWalk = errors.New("end walking tar contents") + +// UntarContext decompresses the provided filename into the outputDir +// Simplified implementation taken from: golang.org/x/build/internal/untar (BSD license) +func UntarContext(ctx context.Context, filename string, outputDir string, cfg TarConfig) error { + return WalkTarFile(ctx, filename, func(tr *tar.Reader, header *tar.Header) error { + rel := stripPathComponents(header.Name, cfg.StripComponents) + // nothing left after stripping + if rel == "" { + return nil + } + + abs := filepath.Join(outputDir, rel) + + return untarFile(tr, abs, header) + }) +} + +// FindFileInTar finds path in the tarFilename contents and processes it via the provided operation +func FindFileInTar(ctx context.Context, tarFilename string, path string, operation func(tr *tar.Reader) error, cfg TarConfig) error { + return WalkTarFile(ctx, tarFilename, func(tr *tar.Reader, header *tar.Header) error { + rel := stripPathComponents(header.Name, cfg.StripComponents) + // nothing left after stripping + if rel == "" { + return nil + } + if rel == path { + if err := operation(tr); err != nil { + return err + } + // We already found it, erarly abort + return ErrEndTarWalk + } + return nil + }) +} + +// WalkTarFile iterates over the list of tar entries and applies the provided operation +func WalkTarFile(ctx context.Context, filename string, operation func(tr *tar.Reader, header *tar.Header) error) error { + fh, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer fh.Close() + gzr, err := gzip.NewReader(fh) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) +Loop: + for { + select { + case <-ctx.Done(): + return fmt.Errorf("cancelled execution") + default: + f, err := tr.Next() + + if err == io.EOF { + break Loop + } + if err != nil { + return fmt.Errorf("failed to read tar file: %w", err) + } + if err := operation(tr, f); err != nil { + if err == ErrEndTarWalk { + break Loop + } + return err + } + } + } + return nil +} + +// IsTarFile checks if the specified filename is a tar.gz file +func IsTarFile(filename string) (bool, error) { + fi, err := os.Stat(filename) + if err != nil { + return false, fmt.Errorf("cannot check file type: %w", err) + } + if fi.Mode().IsDir() { + return false, nil + } + fh, err := os.Open(filename) + if err != nil { + return false, fmt.Errorf("fail to open file: %w", err) + } + defer fh.Close() + gzr, err := gzip.NewReader(fh) + if err != nil { + return false, nil + } + defer gzr.Close() + return true, nil +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..acb8e27 --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,235 @@ +// Package utils implements helper functions +package utils + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath" + "gopkg.in/yaml.v3" +) + +// FileExists checks if filename exists +func FileExists(filename string) bool { + _, err := os.Stat(filename) + return err == nil +} + +func rawYamlSet(n *yaml.Node, path string, value string) error { + p, err := yamlpath.NewPath(path) + + if err != nil { + return fmt.Errorf("cannot create YAML path: %v", err) + } + q, err := p.Find(n) + if err != nil { + return fmt.Errorf("cannot find YAML path %q: %v", path, err) + } + if len(q) == 0 { + return fmt.Errorf("cannot find YAML path %q", path) + } + if len(q) > 1 { + return fmt.Errorf("expected single result replacing image but found %d", len(q)) + } + yamlElement := q[0] + + yamlElement.Value = value + return nil + +} + +// YamlFileSet sets the list of key-value specified in values in the YAML file. +// The keys are in jsonpath format +func YamlFileSet(file string, values map[string]string) error { + data, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("failed to set YAML file %q: %v", file, err) + } + data, err = YamlSet(data, values) + if err != nil { + return fmt.Errorf("failed to set YAML file %q: %v", file, err) + } + return SafeWriteFile(file, data, 0644) +} + +// YamlSet sets the list of key-value specified in values in the YAML data. +// The keys are in jsonpath format +func YamlSet(data []byte, values map[string]string) ([]byte, error) { + var allErrors error + var n yaml.Node + + err := yaml.Unmarshal(data, &n) + if err != nil { + return nil, fmt.Errorf("cannot unmarshal YAML data: %v", err) + } + for path, value := range values { + + if err := rawYamlSet(&n, path, value); err != nil { + allErrors = errors.Join(allErrors, err) + } + } + if allErrors != nil { + return nil, allErrors + } + + var buf bytes.Buffer + e := yaml.NewEncoder(&buf) + e.SetIndent(2) + + err = e.Encode(&n) + if err != nil { + return nil, fmt.Errorf("failed to format YAML: %v", err) + } + if err := e.Close(); err != nil { + return nil, fmt.Errorf("failed to finalize YAML: %v", err) + } + return buf.Bytes(), nil +} + +// SafeWriteFile writes data into the specified filename by first creating it, and then renaming +// to the final destination to minimize breaking the file +func SafeWriteFile(filename string, data []byte, perm os.FileMode) error { + + f, err := os.CreateTemp(filepath.Dir(filename), "tmp") + if err != nil { + return err + } + err = f.Chmod(perm) + if err != nil { + return err + } + tmpname := f.Name() + + // write data to temp file + n, err := f.Write(data) + if err == nil && n < len(data) { + err = io.ErrShortWrite + } + if err1 := f.Close(); err == nil { + err = err1 + } + if err != nil { + return err + } + + return os.Rename(tmpname, filename) +} + +// RelocateImageURL rewrites the provided image url by replacing its prefix +func RelocateImageURL(url string, prefix string, includeIndentifier bool) (string, error) { + ref, err := name.ParseReference(url) + if err != nil { + return "", fmt.Errorf("failed to relocate url: %v", err) + } + normalizedURL := ref.Context().Name() + + // We will preserve the last past of the repository + re := regexp.MustCompile("^.*?/(([^/]+/)?[^/]+)$") + match := re.FindStringSubmatch(normalizedURL) + if match == nil { + return "", fmt.Errorf("failed to parse normalized URL") + } + newURL := fmt.Sprintf("%s/%s", strings.TrimRight(prefix, "/"), match[1]) + if includeIndentifier && ref.Identifier() != "" { + separator := ":" + if _, ok := ref.(name.Digest); ok { + separator = "@" + } + newURL = fmt.Sprintf("%s%s%s", newURL, separator, ref.Identifier()) + } + return newURL, nil +} + +// RelocateImageRegistry rewrites the provided image URL by replacing its +// registry with the newRegistry. If includeIdentifier is true, the tag or +// digest of the image is included in the returned URL. The function returns +// an error if the URL cannot be parsed or the new repository cannot be created. +func RelocateImageRegistry(url string, newRegistry string, + includeIndentifier bool) (string, error) { + ref, err := name.ParseReference(url) + if err != nil { + return "", fmt.Errorf("failed to relocate url: %v", err) + } + + // Create a new repository with the new registry and the repository path + // from the parsed reference + newRepo, err := name.NewRepository(fmt.Sprintf("%s/%s", newRegistry, + ref.Context().RepositoryStr())) + if err != nil { + return "", fmt.Errorf("failed to create new repository: %v", err) + } + + var newRef name.Reference + switch v := ref.(type) { + case name.Tag: + // If the parsed reference is a Tag, create a new Tag with the new + // repository and the tag from the parsed reference + newRef, err = name.NewTag(fmt.Sprintf("%s:%s", newRepo.Name(), + v.TagStr()), name.WeakValidation) + case name.Digest: + // If the parsed reference is a Digest, create a new Digest with the + // new repository and the digest from the parsed reference + newRef, err = name.NewDigest(fmt.Sprintf("%s@%s", newRepo.Name(), + v.DigestStr()), name.WeakValidation) + } + if err != nil { + return "", fmt.Errorf("failed to create new reference: %v", err) + } + + newURL := newRef.Context().Name() + if includeIndentifier && newRef.Identifier() != "" { + separator := ":" + if _, ok := newRef.(name.Digest); ok { + separator = "@" + } + newURL = fmt.Sprintf("%s%s%s", newURL, separator, newRef.Identifier()) + } + + // Return the full name of the new reference, which includes the tag or digest + return newURL, nil +} + +// ExecuteWithRetry executes a function retrying until it succeeds or the number of retries is reached +func ExecuteWithRetry(retries int, cb func(try int, prevErr error) error) error { + retry := 0 + var err error + for { + err = cb(retry, err) + if err == nil { + break + } + if retry < retries { + retry++ + continue + } + return err + } + return nil +} + +// TruncateStringWithEllipsis returns a truncated version of text +func TruncateStringWithEllipsis(text string, maxLength int) string { + if len(text) <= maxLength { + return text + } + if maxLength <= 0 { + return "" + } + + ellipsis := "[...]" + + // If the maxLength is so small the ellipsis does not fit, just return the prefix + if maxLength <= len(ellipsis) { + return text[0:maxLength] + } + startSplit := (maxLength - len(ellipsis)) / 2 + endSplit := len(text) - (maxLength - startSplit - len(ellipsis)) + return text[0:startSplit] + ellipsis + text[endSplit:] +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go new file mode 100644 index 0000000..13ea807 --- /dev/null +++ b/pkg/utils/utils_test.go @@ -0,0 +1,289 @@ +package utils + +import ( + "crypto/sha256" + "fmt" + "log" + "os" + "path/filepath" + "reflect" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tu "github.com/vmware-labs/distribution-tooling-for-helm/internal/testutil" +) + +var ( + sb *tu.Sandbox +) + +func TestMain(m *testing.M) { + sb = tu.NewSandbox() + c := m.Run() + + if err := sb.Cleanup(); err != nil { + log.Printf("WARN: failed to cleanup test sandbox: %v", err) + } + + os.Exit(c) +} + +func TestFileExists(t *testing.T) { + existingFile := sb.Touch(sb.TempFile()) + existingDir, _ := sb.Mkdir(sb.TempFile(), os.FileMode(0755)) + nonTraversableDir, _ := sb.Mkdir(sb.TempFile(), os.FileMode(0000)) + + nonExistingFile := sb.TempFile() + + for path, expected := range map[string]bool{ + existingFile: true, + existingDir: true, + nonTraversableDir: true, + filepath.Join(nonTraversableDir, "dummy.txt"): false, + nonExistingFile: false, + } { + assert.Equal(t, FileExists(path), expected, + "Expected FileExists('%s') to be '%t'", path, expected) + } +} + +func TestYamlFileSet(t *testing.T) { + sampleData := "a:\n b:\n c: hello\n" + + t.Run("Modifies existing file", func(t *testing.T) { + sampleYamlFile := sb.TempFile() + + require.NoError(t, os.WriteFile(sampleYamlFile, []byte(sampleData), 0755)) + assert.NoError(t, YamlFileSet(sampleYamlFile, map[string]string{ + "$.a.b.c": "world", + })) + data, err := os.ReadFile(sampleYamlFile) + require.NoError(t, err) + assert.Equal(t, "a:\n b:\n c: world\n", string(data)) + }) + t.Run("Requires file to exist", func(t *testing.T) { + sampleYamlFile := sb.TempFile() + require.NoFileExists(t, sampleYamlFile) + tu.AssertErrorMatch(t, YamlFileSet(sampleYamlFile, map[string]string{ + "$.a.b.c": "world", + }), regexp.MustCompile("failed to set YAML file.*no such file or directory")) + }) + t.Run("Fails to set non-existing YAML path", func(t *testing.T) { + sampleYamlFile := sb.TempFile() + require.NoError(t, os.WriteFile(sampleYamlFile, []byte(sampleData), 0755)) + tu.AssertErrorMatch(t, YamlFileSet(sampleYamlFile, map[string]string{ + "$.a.b.c.e": "world", + }), regexp.MustCompile(`failed to set YAML file.*cannot find YAML path.*`)) + + }) +} + +func TestYamlSet(t *testing.T) { + tests := []struct { + name string + data string + replace map[string]string + want string + expectedErr string + }{ + { + name: "Basic YamlSet", + + data: "a:\n b:\n c: hello\n", + + replace: map[string]string{"$.a.b.c": "world"}, + + want: "a:\n b:\n c: world\n", + }, + { + name: "Malformed YAML", + data: "\tmalformed\n\tdata", + expectedErr: `cannot unmarshal YAML data: yaml: found character that cannot start any token`, + }, + { + name: "Malformed Replacement Path", + data: "a: b", + replace: map[string]string{"$$": "data"}, + expectedErr: `cannot create YAML path: invalid path syntax at position 1`, + }, + { + name: "Fails if path to replace does not exist", + data: "a: b", + replace: map[string]string{"$.b.c": "data"}, + expectedErr: `cannot find YAML path "$.b.c"`, + }, + { + name: "Fails if finds too many results", + data: "a: b\nc: d", + replace: map[string]string{"$.*": "data"}, + expectedErr: `expected single result replacing image but found 2`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := YamlSet([]byte(tt.data), tt.replace) + validateError(t, tt.expectedErr, err) + if !reflect.DeepEqual(string(got), tt.want) { + t.Errorf("YamlSet() = %v, want %v", string(got), tt.want) + } + }) + } +} + +func TestSafeWriteFile(t *testing.T) { + nonExistingFile := sb.TempFile() + sampleData := "hello world" + assert.NoFileExists(t, nonExistingFile) + assert.NoError(t, SafeWriteFile(nonExistingFile, []byte(sampleData), 0755)) + assert.FileExists(t, nonExistingFile) + data, err := os.ReadFile(nonExistingFile) + assert.NoError(t, err) + if err == nil { + assert.Equal(t, sampleData, string(data)) + } +} + +func TestRelocateImageURL(t *testing.T) { + type args struct { + url string + prefix string + includeIndentifier bool + } + newReg := "mycustom.docker.registry.com/airgap" + dummyTag := "mytag" + dummyDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte("sample"))) + tests := map[string]struct { + args args + want string + expectedErr string + }{ + "Basic replacement": { + args: args{ + url: "bitnami/wordpress:mytag", + prefix: newReg, + }, + want: fmt.Sprintf("%s/bitnami/wordpress", newReg), + }, + "Basic replacement including tag": { + args: args{ + url: fmt.Sprintf("bitnami/wordpress:%s", dummyTag), + prefix: newReg, + includeIndentifier: true, + }, + want: fmt.Sprintf("%s/bitnami/wordpress:%s", newReg, dummyTag), + }, + "Basic replacement including tag and digest gives preference to digest": { + args: args{ + url: fmt.Sprintf("bitnami/wordpress:%s@%s", dummyTag, dummyDigest), + prefix: newReg, + includeIndentifier: true, + }, + want: fmt.Sprintf("%s/bitnami/wordpress@%s", newReg, dummyDigest), + }, + "Replaces full URL with single component": { + args: args{ + url: "example.com:80/foo", + prefix: newReg, + }, + want: fmt.Sprintf("%s/foo", newReg), + }, + "Replaces full URL with multiple components": { + args: args{ + url: "example.com:80/foo/bar/bitnami/app", + prefix: newReg, + }, + want: fmt.Sprintf("%s/foo/bar/bitnami/app", newReg), + }, + "Replaces registry with doubly nested repository prefex": { + args: args{ + url: "www.example.com/docker/abc/imagename", + prefix: newReg, + }, + want: fmt.Sprintf("%s/docker/abc/imagename", newReg), + }, + "Replaces registry with triply nested repository prefix": { + args: args{ + url: "www.example.com/docker/abc/testimages/imagename", + prefix: newReg, + }, + want: fmt.Sprintf("%s/docker/abc/testimages/imagename", newReg), + }, + "Replaces library repositoy": { + args: args{ + url: "foo", + prefix: newReg, + }, + want: fmt.Sprintf("%s/library/foo", newReg), + }, + "Fails on malformed urls": { + args: args{ + url: "incorrect:::url", + prefix: newReg, + }, + expectedErr: "failed to relocate url: could not parse reference", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := RelocateImageRegistry(tt.args.url, tt.args.prefix, tt.args.includeIndentifier) + validateError(t, tt.expectedErr, err) + + if got != tt.want { + t.Errorf("RelocateImageURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func validateError(t *testing.T, expectedErr string, err error) { + if expectedErr != "" { + if err == nil { + t.Errorf("expected error %q but it did not fail", expectedErr) + } else { + assert.ErrorContains(t, err, expectedErr) + } + + } else if err != nil { + t.Errorf("got error = %v but expected to succeed", err) + } +} + +func TestTruncateStringWithEllipsis(t *testing.T) { + + tests := map[string]struct { + text string + maxLength int + want string + }{ + "String short enough": { + "hello world", 20, "hello world", + }, + "Truncated string odd length": { + "This is a long string to truncate", 15, "This [...]ncate", + }, + "Truncated string even length": { + "This is a long string to truncate", 20, "This is[...]truncate", + }, + "Max length too small": { + "This is a long string to truncate", 5, "This ", + }, + "Max length too small (2)": { + "This is a long string to truncate", 1, "T", + }, + "Max length too small (3)": { + "This is a long string to truncate", 0, "", + }, + "Negative max length too small": { + "This is a long string to truncate", -5, "", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := TruncateStringWithEllipsis(tt.text, tt.maxLength); got != tt.want { + t.Errorf("TruncateStringWithEllipsis() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/wrapping/wrap.go b/pkg/wrapping/wrap.go new file mode 100644 index 0000000..0558cc5 --- /dev/null +++ b/pkg/wrapping/wrap.go @@ -0,0 +1,122 @@ +// Package wrapping defines methods to handle Helm chart wraps +package wrapping + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/artifacts" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/chartutils" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/imagelock" + "github.com/vmware-labs/distribution-tooling-for-helm/pkg/utils" + + "helm.sh/helm/v3/pkg/chart/loader" +) + +// Lockable defines the interface to support getting images locked +type Lockable interface { + LockFilePath() string + ImagesDir() string + GetImagesLock() (*imagelock.ImagesLock, error) +} + +// Wrap defines the interface to implement a Helm chart wrap +type Wrap interface { + Lockable + VerifyLock(...imagelock.Option) error + + Chart() *chartutils.Chart + RootDir() string + ChartDir() string + ImageArtifactsDir() string +} + +// wrap defines a wrapped chart +type wrap struct { + rootDir string + chart *chartutils.Chart +} + +// RootDir returns the path to the Wrap root directory +func (w *wrap) RootDir() string { + return w.rootDir +} + +// LockFilePath returns the absolute path to the chart Images.lock +func (w *wrap) LockFilePath() string { + return filepath.Join(w.ChartDir(), imagelock.DefaultImagesLockFileName) +} + +// ImageArtifactsDir returns the imags artifacts directory +func (w *wrap) ImageArtifactsDir() string { + return filepath.Join(w.RootDir(), artifacts.HelmArtifactsFolder, "images") +} + +// ImagesDir returns the images directory inside the chart root directory +func (w *wrap) ImagesDir() string { + return w.AbsFilePath("images") +} + +// GetImagesLock returns the chart's ImagesLock object +func (w *wrap) GetImagesLock() (*imagelock.ImagesLock, error) { + return w.chart.GetImagesLock() +} + +// AbsFilePath returns the absolute path to the Chart relative file name +func (w *wrap) AbsFilePath(name string) string { + return filepath.Join(w.rootDir, name) +} + +// ChartDir returns the path to the Helm chart +func (w *wrap) ChartDir() string { + return w.chart.RootDir() +} + +// Chart returns the Chart object +func (w *wrap) Chart() *chartutils.Chart { + return w.chart +} + +func (w *wrap) VerifyLock(opts ...imagelock.Option) error { + return w.chart.VerifyLock(opts...) +} + +// Load loads a directory containing a wrapped chart and returns a Wrap +func Load(dir string, opts ...chartutils.Option) (Wrap, error) { + chartDir := filepath.Join(dir, "chart") + chart, err := chartutils.LoadChart(chartDir, opts...) + if err != nil { + return nil, err + } + + return &wrap{rootDir: dir, chart: chart}, nil +} + +// Create receives a path to a source Helm chart and a destination directory where to wrap it and returns a Wrap +func Create(chartSrc string, destDir string, opts ...chartutils.Option) (Wrap, error) { + // Check we got a chart dir + _, err := loader.Load(chartSrc) + if err != nil { + return nil, fmt.Errorf("failed to load Helm chart: %v", err) + } + + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create wrap root directory: %w", err) + } + + wrapChartDir := filepath.Join(destDir, "chart") + if utils.FileExists(wrapChartDir) { + return nil, fmt.Errorf("chart dir %q already exists", wrapChartDir) + } + + if err := utils.CopyDir(chartSrc, wrapChartDir); err != nil { + return nil, fmt.Errorf("failed to copy source chart: %w", err) + } + + chart, err := chartutils.LoadChart(wrapChartDir, opts...) + if err != nil { + return nil, fmt.Errorf("failed to load Helm chart: %w", err) + } + return &wrap{rootDir: destDir, chart: chart}, nil +} diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..11c5e1b --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,8 @@ +name: "dt" +version: "0.4.1" +usage: "Distribution Tooling for Helm" +description: "Distribution Tooling for Helm" +command: "$HELM_PLUGIN_DIR/bin/dt" +hooks: + install: "$HELM_PLUGIN_DIR/install-binary.sh" + update: "$HELM_PLUGIN_DIR/install-binary.sh -u" diff --git a/testdata/images.json b/testdata/images.json new file mode 100644 index 0000000..cd552a0 --- /dev/null +++ b/testdata/images.json @@ -0,0 +1,86 @@ +[ +{ + "Name": "apache-exporter", + "Image": "bitnami/apache-exporter:0.13.4-debian-11-r2", + "Digests": [ + { + "Arch": "linux/amd64", + "Digest": "sha256:83acfe5b679dfc843692fc3d122eb7c18f4733855e0c03ba22fa301c2dbf8c05" + }, + { + "Arch": "linux/arm64", + "Digest": "sha256:50ede0624e286591351daa96b86b3e3c8826d699f931a9f854ecbc186ae6ab1c" + } + ] + }, + { + "Name": "mariadb", + "Image": "bitnami/mariadb:10.11.4-debian-11-r0", + "Digests": [ + { + "Arch": "linux/amd64", + "Digest": "sha256:d8fd0e4cdd52e10c05a165eeacdf639ac0dee12e036a62d2ab3ccec42352c7c5" + }, + { + "Arch": "linux/arm64", + "Digest": "sha256:7dd6e0d680eea4b7b00cef9dfe4b1c80ef7447db7ab21c51a2c3b8f7c0375ba3" + } + ] + }, + { + "Name": "mysqld-exporter", + "Image": "bitnami/mysqld-exporter:0.14.0-debian-11-r125", + "Digests": [ + { + "Arch": "linux/amd64", + "Digest": "sha256:3ae642840c29e2541c63871e8d9e0f8f7a8bc5c45eb0e20afa9d134242a72d12" + }, + { + "Arch": "linux/arm64", + "Digest": "sha256:82f5ebe3529a6cb3ec6a07daf819c1ee881d472fef297bb1f7c4b5d1d0634fea" + } + ] + }, + { + "Name": "bitnami-shell", + "Image": "bitnami/bitnami-shell:11-debian-11-r124", + "Digests": [ + { + "Arch": "linux/amd64", + "Digest": "sha256:9d9195f30a8c8a82db434acd2e9c7b02f366be17a419245a7856a61117be063f" + }, + { + "Arch": "linux/arm64", + "Digest": "sha256:296dc1939f70667553ac6d3787b5b69561a5590e2719b841e2b26d3b65ba6515" + } + ] + }, + { + "Name": "bitnami-shell", + "Image": "bitnami/bitnami-shell:11-debian-11-r123", + "Digests": [ + { + "Arch": "linux/amd64", + "Digest": "sha256:cc2370abc4a5d86dcaab4cb6e6e6a58f99fcc6f95b02752cabf8ba193f78d78e" + }, + { + "Arch": "linux/arm64", + "Digest": "sha256:5b7bd35e7935988160f3031766a51665bb709767d24f1e11ca44fc671446f486" + } + ] + }, + { + "Name": "wordpress", + "Image": "bitnami/wordpress:6.2.2-debian-11-r11", + "Digests": [ + { + "Arch": "linux/amd64", + "Digest": "sha256:a410341508b8823774448bc457730e38d2f047497ffddf090553845493c62e0f" + }, + { + "Arch": "linux/arm64", + "Digest": "sha256:1e5991a54bc98871e61dd7f94697f86b5dc4e2b2560d5590ff292038a6434ba7" + } + ] + } +] diff --git a/testdata/scenarios/chart1/Chart.yaml.tmpl b/testdata/scenarios/chart1/Chart.yaml.tmpl new file mode 100644 index 0000000..ae90b74 --- /dev/null +++ b/testdata/scenarios/chart1/Chart.yaml.tmpl @@ -0,0 +1,14 @@ +name: wordpress +version: 1.0.0 +annotations: + category: CMS + licenses: Apache-2.0 + {{if .AnnotationsKey}}{{.AnnotationsKey}}{{else}}images{{end}}: | +{{include "images.partial.tmpl" . | indent 6 }} +dependencies: + - name: mariadb + repository: oci://registry-1.docker.io/bitnamicharts + version: 12.x.x + - name: common + repository: oci://registry-1.docker.io/bitnamicharts + version: 2.x.x diff --git a/testdata/scenarios/chart1/Images.lock.tmpl b/testdata/scenarios/chart1/Images.lock.tmpl new file mode 100644 index 0000000..a7bbdc3 --- /dev/null +++ b/testdata/scenarios/chart1/Images.lock.tmpl @@ -0,0 +1,11 @@ +apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "2023-07-13T16:30:33.284125307Z" + generatedBy: Distribution Tooling for Helm +chart: + name: wordpress + version: 1.0.0 + appVersion: "" +images: +{{include "lock_images.partial.tmpl" . | indent 2 }} diff --git a/testdata/scenarios/chart1/charts/common/Chart.yaml b/testdata/scenarios/chart1/charts/common/Chart.yaml new file mode 100644 index 0000000..2dc4369 --- /dev/null +++ b/testdata/scenarios/chart1/charts/common/Chart.yaml @@ -0,0 +1,17 @@ +annotations: + category: Infrastructure + licenses: Apache-2.0 +apiVersion: v2 +appVersion: 2.6.0 +description: A Library Helm chart for grouping common logic between bitnami charts. + This chart is not deployable by itself. +home: https://bitnami.com +icon: https://bitnami.com/downloads/logos/bitnami-mark.png +name: common +type: library +version: 2.6.0 +dependencies: + - name: common2 + repository: oci://registry-1.docker.io/bitnamicharts + version: 2.x.x + diff --git a/testdata/scenarios/chart1/charts/common/charts/common2/Chart.yaml b/testdata/scenarios/chart1/charts/common/charts/common2/Chart.yaml new file mode 100644 index 0000000..3e80239 --- /dev/null +++ b/testdata/scenarios/chart1/charts/common/charts/common2/Chart.yaml @@ -0,0 +1,12 @@ +annotations: + category: Infrastructure + licenses: Apache-2.0 +apiVersion: v2 +appVersion: 2.6.0 +description: A Library Helm chart for grouping common logic between bitnami charts. + This chart is not deployable by itself. +home: https://bitnami.com +icon: https://bitnami.com/downloads/logos/bitnami-mark.png +name: common +type: library +version: 2.6.0 diff --git a/testdata/scenarios/chart1/charts/mariadb/Chart.lock b/testdata/scenarios/chart1/charts/mariadb/Chart.lock new file mode 100644 index 0000000..cc6e4f1 --- /dev/null +++ b/testdata/scenarios/chart1/charts/mariadb/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: common + repository: oci://registry-1.docker.io/bitnamicharts + version: 2.4.0 +digest: sha256:8c1a5dc923412d11d4d841420494b499cb707305c8b9f87f45ea1a8bf3172cb3 +generated: "2023-05-21T18:46:17.326179513Z" diff --git a/testdata/scenarios/chart1/charts/mariadb/Chart.yaml.tmpl b/testdata/scenarios/chart1/charts/mariadb/Chart.yaml.tmpl new file mode 100644 index 0000000..460c6bc --- /dev/null +++ b/testdata/scenarios/chart1/charts/mariadb/Chart.yaml.tmpl @@ -0,0 +1,17 @@ +annotations: + category: Database + images: | + - image: {{.ServerURL}}/bitnami/mysqld-exporter:0.14.0-debian-11-r125 + name: mysqld-exporter + - image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r123 + name: bitnami-shell + - image: {{.ServerURL}}/bitnami/mariadb:10.11.4-debian-11-r0 + name: mariadb + licenses: Apache-2.0 +apiVersion: v2 +appVersion: 10.11.4 +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. +home: https://bitnami.com +icon: https://bitnami.com/assets/stacks/mariadb/img/mariadb-stack-220x234.png +name: mariadb +version: 12.2.5 diff --git a/testdata/scenarios/chart1/images.partial.tmpl b/testdata/scenarios/chart1/images.partial.tmpl new file mode 100644 index 0000000..58ba143 --- /dev/null +++ b/testdata/scenarios/chart1/images.partial.tmpl @@ -0,0 +1,6 @@ +- name: wordpress + image: {{.ServerURL}}/bitnami/wordpress:6.2.2-debian-11-r11 +- name: bitnami-shell + image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r124 +- name: apache-exporter + image: {{.ServerURL}}/bitnami/apache-exporter:0.13.4-debian-11-r2 diff --git a/testdata/scenarios/chart1/lock_images.partial.tmpl b/testdata/scenarios/chart1/lock_images.partial.tmpl new file mode 100644 index 0000000..a1f6397 --- /dev/null +++ b/testdata/scenarios/chart1/lock_images.partial.tmpl @@ -0,0 +1,49 @@ +- name: wordpress + image: {{.ServerURL}}/bitnami/wordpress:6.2.2-debian-11-r11 + chart: wordpress + digests: + - digest: sha256:a410341508b8823774448bc457730e38d2f047497ffddf090553845493c62e0f + arch: linux/amd64 + - digest: sha256:1e5991a54bc98871e61dd7f94697f86b5dc4e2b2560d5590ff292038a6434ba7 + arch: linux/arm64 +- name: bitnami-shell + image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r124 + chart: wordpress + digests: + - digest: sha256:9d9195f30a8c8a82db434acd2e9c7b02f366be17a419245a7856a61117be063f + arch: linux/amd64 + - digest: sha256:296dc1939f70667553ac6d3787b5b69561a5590e2719b841e2b26d3b65ba6515 + arch: linux/arm64 +- name: apache-exporter + image: {{.ServerURL}}/bitnami/apache-exporter:0.13.4-debian-11-r2 + chart: wordpress + digests: + - digest: sha256:83acfe5b679dfc843692fc3d122eb7c18f4733855e0c03ba22fa301c2dbf8c05 + arch: linux/amd64 + - digest: sha256:50ede0624e286591351daa96b86b3e3c8826d699f931a9f854ecbc186ae6ab1c + arch: linux/arm64 +- name: mysqld-exporter + image: {{.ServerURL}}/bitnami/mysqld-exporter:0.14.0-debian-11-r125 + chart: mariadb + digests: + - digest: sha256:3ae642840c29e2541c63871e8d9e0f8f7a8bc5c45eb0e20afa9d134242a72d12 + arch: linux/amd64 + - digest: sha256:82f5ebe3529a6cb3ec6a07daf819c1ee881d472fef297bb1f7c4b5d1d0634fea + arch: linux/arm64 +- name: bitnami-shell + image: {{.ServerURL}}/bitnami/bitnami-shell:11-debian-11-r123 + chart: mariadb + digests: + - digest: sha256:cc2370abc4a5d86dcaab4cb6e6e6a58f99fcc6f95b02752cabf8ba193f78d78e + arch: linux/amd64 + - digest: sha256:5b7bd35e7935988160f3031766a51665bb709767d24f1e11ca44fc671446f486 + arch: linux/arm64 +- name: mariadb + image: {{.ServerURL}}/bitnami/mariadb:10.11.4-debian-11-r0 + chart: mariadb + digests: + - digest: sha256:d8fd0e4cdd52e10c05a165eeacdf639ac0dee12e036a62d2ab3ccec42352c7c5 + arch: linux/amd64 + - digest: sha256:7dd6e0d680eea4b7b00cef9dfe4b1c80ef7447db7ab21c51a2c3b8f7c0375ba3 + arch: linux/arm64 + diff --git a/testdata/scenarios/chart1/values.prod.yaml.tmpl b/testdata/scenarios/chart1/values.prod.yaml.tmpl new file mode 100644 index 0000000..2ef7d17 --- /dev/null +++ b/testdata/scenarios/chart1/values.prod.yaml.tmpl @@ -0,0 +1,4 @@ +image: + registry: {{.ServerURL}} + repository: {{if .RepositoryPrefix}}{{.RepositoryPrefix}}/{{end}}bitnami/wordpress + tag: 6.2.2-debian-11-r26 diff --git a/testdata/scenarios/chart1/values.yaml.tmpl b/testdata/scenarios/chart1/values.yaml.tmpl new file mode 100644 index 0000000..2ef7d17 --- /dev/null +++ b/testdata/scenarios/chart1/values.yaml.tmpl @@ -0,0 +1,4 @@ +image: + registry: {{.ServerURL}} + repository: {{if .RepositoryPrefix}}{{.RepositoryPrefix}}/{{end}}bitnami/wordpress + tag: 6.2.2-debian-11-r26 diff --git a/testdata/scenarios/complete-chart/Chart.yaml.tmpl b/testdata/scenarios/complete-chart/Chart.yaml.tmpl new file mode 100644 index 0000000..3aee831 --- /dev/null +++ b/testdata/scenarios/complete-chart/Chart.yaml.tmpl @@ -0,0 +1,18 @@ +name: {{or .Name "WordPress"}} +version: {{or .Version "1.0.0"}} +annotations: + category: CMS + licenses: Apache-2.0 + {{if .AnnotationsKey}}{{.AnnotationsKey}}{{else}}images{{end}}: | +{{- include "images.partial.tmpl" . | indent 6 }} +appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} +{{if .Dependencies }} +{{if gt (len .Dependencies) 0 }} +dependencies: +{{- range .Dependencies}} + - name: {{.Name}} + repository: {{.Repository}} + version: {{.Version}} +{{end -}} +{{end}} +{{end}} diff --git a/testdata/scenarios/complete-chart/Images.lock.tmpl b/testdata/scenarios/complete-chart/Images.lock.tmpl new file mode 100644 index 0000000..6b28a60 --- /dev/null +++ b/testdata/scenarios/complete-chart/Images.lock.tmpl @@ -0,0 +1 @@ +{{include "imagelock.partial.tmpl" . }} \ No newline at end of file diff --git a/testdata/scenarios/complete-chart/imagelock.partial.tmpl b/testdata/scenarios/complete-chart/imagelock.partial.tmpl new file mode 100644 index 0000000..80ec2e6 --- /dev/null +++ b/testdata/scenarios/complete-chart/imagelock.partial.tmpl @@ -0,0 +1,21 @@ +apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "2023-07-13T16:30:33.284125307Z" + generatedBy: Distribution Tooling for Helm +chart: + name: {{.Name}} + version: 1.0.0 + appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} +images: +{{- $p := . -}} +{{- range $idx, $elem := .Images}} + - name: {{$elem.Name}} + image: {{$p.ServerURL}}/{{$elem.Image}} + chart: {{$p.Name}} + digests: +{{- range .Digests}} + - digest: {{.Digest}} + arch: {{.Arch}} +{{- end}} +{{- end}} diff --git a/testdata/scenarios/complete-chart/images.partial.tmpl b/testdata/scenarios/complete-chart/images.partial.tmpl new file mode 100644 index 0000000..aecdf09 --- /dev/null +++ b/testdata/scenarios/complete-chart/images.partial.tmpl @@ -0,0 +1,5 @@ +{{- $p := . -}} +{{- range .Images}} +- name: {{.Name}} + image: {{if $p.RepositoryURL}}{{$p.RepositoryURL}}/{{end}}{{.Image}} +{{- end }} diff --git a/testdata/scenarios/custom-chart/.imgpkg/bundle.yml.tmpl b/testdata/scenarios/custom-chart/.imgpkg/bundle.yml.tmpl new file mode 100644 index 0000000..e91eecb --- /dev/null +++ b/testdata/scenarios/custom-chart/.imgpkg/bundle.yml.tmpl @@ -0,0 +1,16 @@ +version: + apiversion: imgpkg.carvel.dev/v1alpha1 + kind: Bundle +metadata: + category: CMS + licenses: Apache-2.0 + name: {{or .Name "WordPress"}} +authors: +{{- range .Authors}} + - name: {{.Name}} + email: {{.Email}} +{{end -}} +websites: +{{- range .Websites}} + - url: {{.URL}} +{{end -}} diff --git a/testdata/scenarios/custom-chart/.imgpkg/images.yml.tmpl b/testdata/scenarios/custom-chart/.imgpkg/images.yml.tmpl new file mode 100644 index 0000000..315f6cb --- /dev/null +++ b/testdata/scenarios/custom-chart/.imgpkg/images.yml.tmpl @@ -0,0 +1,15 @@ +apiVersion: imgpkg.carvel.dev/v1alpha1 +images: +{{- $p := . -}} +{{- range $idx, $elem := .Images}} +{{ $imageParts := split ":" $elem.Image }} +{{ $img := $imageParts._0 }} +{{- range .Digests}} +{{- if eq .Arch "linux/amd64"}} +- annotations: + kbld.carvel.dev/id: {{$p.ServerURL}}/{{$elem.Image}} + image: {{$p.ServerURL}}/{{$img}}@{{.Digest}} +{{- end }} +{{- end}} +{{- end}} +kind: ImagesLock \ No newline at end of file diff --git a/testdata/scenarios/custom-chart/Chart.yaml.tmpl b/testdata/scenarios/custom-chart/Chart.yaml.tmpl new file mode 100644 index 0000000..455b080 --- /dev/null +++ b/testdata/scenarios/custom-chart/Chart.yaml.tmpl @@ -0,0 +1,35 @@ +name: {{or .Name "WordPress"}} +version: {{or .Version "1.0.0"}} +annotations: + category: CMS + licenses: Apache-2.0 + {{if .AnnotationsKey}}{{.AnnotationsKey}}{{else}}images{{end}}: | +{{- include "images.partial.tmpl" . | indent 6 }} +appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} +{{if .Dependencies }} +{{if gt (len .Dependencies) 0 }} +dependencies: +{{- range .Dependencies}} + - name: {{.Name}} + repository: {{.Repository}} + version: {{.Version}} +{{end -}} +{{end}} +{{end}} +{{if .Authors }} +{{if gt (len .Authors) 0 }} +maintainers: +{{- range .Authors}} + - name: {{.Name}} + email: {{.Email}} +{{end -}} +{{end}} +{{end}} +{{if .Websites }} +{{if gt (len .Websites) 0 }} +sources: +{{- range .Websites}} + - {{.URL}} +{{end -}} +{{end}} +{{end}} diff --git a/testdata/scenarios/custom-chart/imagelock.partial.tmpl b/testdata/scenarios/custom-chart/imagelock.partial.tmpl new file mode 100644 index 0000000..80ec2e6 --- /dev/null +++ b/testdata/scenarios/custom-chart/imagelock.partial.tmpl @@ -0,0 +1,21 @@ +apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "2023-07-13T16:30:33.284125307Z" + generatedBy: Distribution Tooling for Helm +chart: + name: {{.Name}} + version: 1.0.0 + appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} +images: +{{- $p := . -}} +{{- range $idx, $elem := .Images}} + - name: {{$elem.Name}} + image: {{$p.ServerURL}}/{{$elem.Image}} + chart: {{$p.Name}} + digests: +{{- range .Digests}} + - digest: {{.Digest}} + arch: {{.Arch}} +{{- end}} +{{- end}} diff --git a/testdata/scenarios/custom-chart/images.partial.tmpl b/testdata/scenarios/custom-chart/images.partial.tmpl new file mode 100644 index 0000000..aecdf09 --- /dev/null +++ b/testdata/scenarios/custom-chart/images.partial.tmpl @@ -0,0 +1,5 @@ +{{- $p := . -}} +{{- range .Images}} +- name: {{.Name}} + image: {{if $p.RepositoryURL}}{{$p.RepositoryURL}}/{{end}}{{.Image}} +{{- end }} diff --git a/testdata/scenarios/no-images-chart/Chart.yaml.tmpl b/testdata/scenarios/no-images-chart/Chart.yaml.tmpl new file mode 100644 index 0000000..506a7ce --- /dev/null +++ b/testdata/scenarios/no-images-chart/Chart.yaml.tmpl @@ -0,0 +1,16 @@ +name: {{or .Name "WordPress"}} +version: {{or .Version "1.0.0"}} +annotations: + category: CMS + licenses: Apache-2.0 +appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} +{{if .Dependencies }} +{{if gt (len .Dependencies) 0 }} +dependencies: +{{- range .Dependencies}} + - name: {{.Name}} + repository: {{.Repository}} + version: {{.Version}} +{{end -}} +{{end}} +{{end}} diff --git a/testdata/scenarios/no-images-chart/Images.lock.tmpl b/testdata/scenarios/no-images-chart/Images.lock.tmpl new file mode 100644 index 0000000..6b28a60 --- /dev/null +++ b/testdata/scenarios/no-images-chart/Images.lock.tmpl @@ -0,0 +1 @@ +{{include "imagelock.partial.tmpl" . }} \ No newline at end of file diff --git a/testdata/scenarios/no-images-chart/imagelock.partial.tmpl b/testdata/scenarios/no-images-chart/imagelock.partial.tmpl new file mode 100644 index 0000000..5c3996a --- /dev/null +++ b/testdata/scenarios/no-images-chart/imagelock.partial.tmpl @@ -0,0 +1,10 @@ +apiVersion: v0 +kind: ImagesLock +metadata: + generatedAt: "2023-07-13T16:30:33.284125307Z" + generatedBy: Distribution Tooling for Helm +chart: + name: {{.Name}} + version: 1.0.0 + appVersion: {{if .AppVersion}}{{.AppVersion}}{{else}}6.2.2{{end}} +images: [] diff --git a/testdata/scenarios/plain-chart/Chart.yaml.tmpl b/testdata/scenarios/plain-chart/Chart.yaml.tmpl new file mode 100644 index 0000000..84e5449 --- /dev/null +++ b/testdata/scenarios/plain-chart/Chart.yaml.tmpl @@ -0,0 +1,2 @@ +name: wordpress +version: 1.0.0 diff --git a/testdata/scenarios/plain-chart/values.yaml.tmpl b/testdata/scenarios/plain-chart/values.yaml.tmpl new file mode 100644 index 0000000..a7bee7c --- /dev/null +++ b/testdata/scenarios/plain-chart/values.yaml.tmpl @@ -0,0 +1,8 @@ +{{if .ValuesImages}} +{{range .ValuesImages}} +{{.Name}}: + registry: {{.Registry}} + repository: {{.Repository}} + tag: {{.Tag}} +{{end}} +{{end}}