mirror of
https://github.com/fluxcd/flux2.git
synced 2026-02-22 15:41:47 +00:00
Merge a3fc5a92e4 into d13dec297a
This commit is contained in:
commit
f455ff0b1f
7 changed files with 558 additions and 26 deletions
|
|
@ -17,24 +17,48 @@ limitations under the License.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"bitbucket.org/creachadair/stringset"
|
||||||
oci "github.com/fluxcd/pkg/oci/client"
|
oci "github.com/fluxcd/pkg/oci/client"
|
||||||
|
"github.com/fluxcd/pkg/tar"
|
||||||
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||||
|
"github.com/gonvenience/ytbx"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/hexops/gotextdiff"
|
||||||
|
"github.com/hexops/gotextdiff/myers"
|
||||||
|
"github.com/hexops/gotextdiff/span"
|
||||||
|
"github.com/homeport/dyff/pkg/dyff"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/exp/maps"
|
||||||
|
|
||||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||||
|
"github.com/fluxcd/flux2/v2/pkg/printers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrDiffArtifactChanged = errors.New("the artifact contents differ")
|
||||||
|
|
||||||
var diffArtifactCmd = &cobra.Command{
|
var diffArtifactCmd = &cobra.Command{
|
||||||
Use: "artifact",
|
Use: "artifact <from> <to>",
|
||||||
Short: "Diff Artifact",
|
Short: "Diff Artifact",
|
||||||
Long: withPreviewNote(`The diff artifact command computes the diff between the remote OCI artifact and a local directory or file`),
|
Long: withPreviewNote(fmt.Sprintf(
|
||||||
|
"The diff artifact command prints the diff between the remote OCI artifact and a local directory or file.\n\n"+
|
||||||
|
"You can overwrite the command used for diffing by setting the %q environment variable.", externalDiffVar)),
|
||||||
Example: `# Check if local files differ from remote
|
Example: `# Check if local files differ from remote
|
||||||
flux diff artifact oci://ghcr.io/stefanprodan/manifests:podinfo:6.2.0 --path=./kustomize`,
|
flux diff artifact oci://ghcr.io/stefanprodan/manifests:podinfo:6.2.0 ./kustomize`,
|
||||||
RunE: diffArtifactCmdRun,
|
RunE: diffArtifactCmdRun,
|
||||||
|
Args: cobra.RangeArgs(1, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
type diffArtifactFlags struct {
|
type diffArtifactFlags struct {
|
||||||
|
|
@ -42,6 +66,8 @@ type diffArtifactFlags struct {
|
||||||
creds string
|
creds string
|
||||||
provider flags.SourceOCIProvider
|
provider flags.SourceOCIProvider
|
||||||
ignorePaths []string
|
ignorePaths []string
|
||||||
|
brief bool
|
||||||
|
differ *differFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
var diffArtifactArgs = newDiffArtifactArgs()
|
var diffArtifactArgs = newDiffArtifactArgs()
|
||||||
|
|
@ -49,34 +75,58 @@ var diffArtifactArgs = newDiffArtifactArgs()
|
||||||
func newDiffArtifactArgs() diffArtifactFlags {
|
func newDiffArtifactArgs() diffArtifactFlags {
|
||||||
return diffArtifactFlags{
|
return diffArtifactFlags{
|
||||||
provider: flags.SourceOCIProvider(sourcev1.GenericOCIProvider),
|
provider: flags.SourceOCIProvider(sourcev1.GenericOCIProvider),
|
||||||
|
|
||||||
|
differ: &differFlag{
|
||||||
|
options: map[string]differ{
|
||||||
|
"dyff": dyffBuiltin{
|
||||||
|
opts: []dyff.CompareOption{
|
||||||
|
dyff.IgnoreOrderChanges(false),
|
||||||
|
dyff.KubernetesEntityDetection(true),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"external": externalDiff{},
|
||||||
|
"unified": unifiedDiff{},
|
||||||
|
},
|
||||||
|
description: map[string]string{
|
||||||
|
"dyff": `semantic diff for YAML inputs`,
|
||||||
|
"external": `execute the command in the "` + externalDiffVar + `" environment variable`,
|
||||||
|
"unified": "generic unified diff for arbitrary text inputs",
|
||||||
|
},
|
||||||
|
value: "unified",
|
||||||
|
differ: unifiedDiff{},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
diffArtifactCmd.Flags().StringVar(&diffArtifactArgs.path, "path", "", "path to the directory where the Kubernetes manifests are located")
|
diffArtifactCmd.Flags().StringVar(&diffArtifactArgs.path, "path", "", "path to the directory or file containing the Kubernetes manifests (deprecated, use a second positional argument instead)")
|
||||||
diffArtifactCmd.Flags().StringVar(&diffArtifactArgs.creds, "creds", "", "credentials for OCI registry in the format <username>[:<password>] if --provider is generic")
|
diffArtifactCmd.Flags().StringVar(&diffArtifactArgs.creds, "creds", "", "credentials for OCI registry in the format <username>[:<password>] if --provider is generic")
|
||||||
diffArtifactCmd.Flags().Var(&diffArtifactArgs.provider, "provider", sourceOCIRepositoryArgs.provider.Description())
|
diffArtifactCmd.Flags().Var(&diffArtifactArgs.provider, "provider", sourceOCIRepositoryArgs.provider.Description())
|
||||||
diffArtifactCmd.Flags().StringSliceVar(&diffArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
|
diffArtifactCmd.Flags().StringSliceVar(&diffArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
|
||||||
|
diffArtifactCmd.Flags().BoolVarP(&diffArtifactArgs.brief, "brief", "q", false, "just print a line when the resources differ; does not output a list of changes")
|
||||||
|
diffArtifactCmd.Flags().Var(diffArtifactArgs.differ, "differ", diffArtifactArgs.differ.usage())
|
||||||
|
|
||||||
diffCmd.AddCommand(diffArtifactCmd)
|
diffCmd.AddCommand(diffArtifactCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func diffArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
func diffArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
|
var from, to string
|
||||||
|
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return fmt.Errorf("artifact URL is required")
|
return fmt.Errorf("artifact URL is required")
|
||||||
}
|
}
|
||||||
ociURL := args[0]
|
from = args[0]
|
||||||
|
|
||||||
if diffArtifactArgs.path == "" {
|
switch {
|
||||||
return fmt.Errorf("invalid path %q", diffArtifactArgs.path)
|
case len(args) >= 2:
|
||||||
}
|
to = args[1]
|
||||||
|
|
||||||
url, err := oci.ParseArtifactURL(ociURL)
|
case diffArtifactArgs.path != "":
|
||||||
if err != nil {
|
// for backwards compatibility
|
||||||
return err
|
to = diffArtifactArgs.path
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(diffArtifactArgs.path); err != nil {
|
default:
|
||||||
return fmt.Errorf("invalid path '%s', must point to an existing directory or file", diffArtifactArgs.path)
|
return errors.New("a second artifact is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
|
||||||
|
|
@ -98,15 +148,402 @@ func diffArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("provider not supported: %w", err)
|
return fmt.Errorf("provider not supported: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ociClient.LoginWithProvider(ctx, url, ociProvider); err != nil {
|
if url, err := oci.ParseArtifactURL(from); err == nil {
|
||||||
return fmt.Errorf("error during login with provider: %w", err)
|
if err := ociClient.LoginWithProvider(ctx, url, ociProvider); err != nil {
|
||||||
|
return fmt.Errorf("error during login with provider: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if url, err := oci.ParseArtifactURL(to); err == nil {
|
||||||
|
if err := ociClient.LoginWithProvider(ctx, url, ociProvider); err != nil {
|
||||||
|
return fmt.Errorf("error during login with provider: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ociClient.Diff(ctx, url, diffArtifactArgs.path, diffArtifactArgs.ignorePaths); err != nil {
|
diff, err := diffArtifact(ctx, ociClient, from, to, diffArtifactArgs)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Successf("no changes detected")
|
if diff == "" {
|
||||||
|
logger.Successf("no changes detected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !diffArtifactArgs.brief {
|
||||||
|
cmd.Print(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%q and %q: %w", from, to, ErrDiffArtifactChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMatcher(ignorePaths []string) gitignore.Matcher {
|
||||||
|
var patterns []gitignore.Pattern
|
||||||
|
|
||||||
|
for _, path := range ignorePaths {
|
||||||
|
patterns = append(patterns, gitignore.ParsePattern(path, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gitignore.NewMatcher(patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffArtifact(ctx context.Context, client *oci.Client, from, to string, flags diffArtifactFlags) (string, error) {
|
||||||
|
fromDir, fromCleanup, err := loadArtifact(ctx, client, from)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer fromCleanup()
|
||||||
|
|
||||||
|
toDir, toCleanup, err := loadArtifact(ctx, client, to)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer toCleanup()
|
||||||
|
|
||||||
|
return flags.differ.Diff(ctx, fromDir, toDir, flags.ignorePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadArtifact ensures that the artifact is in a local directory that can be
|
||||||
|
// recursively diffed. If necessary, files are downloaded, extracted, and/or
|
||||||
|
// copied into temporary directories for this purpose.
|
||||||
|
func loadArtifact(ctx context.Context, client *oci.Client, path string) (dir string, cleanup func(), err error) {
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err == nil && fi.IsDir() {
|
||||||
|
return path, func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && fi.Mode().IsRegular() {
|
||||||
|
return loadArtifactFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := oci.ParseArtifactURL(path)
|
||||||
|
if err == nil {
|
||||||
|
return loadArtifactOCI(ctx, client, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil, fmt.Errorf("%q: %w", path, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadArtifactOCI pulls the remove artifact into a temporary directory.
|
||||||
|
func loadArtifactOCI(ctx context.Context, client *oci.Client, url string) (dir string, cleanup func(), err error) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "flux-diff-artifact")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("could not create temporary directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = func() {
|
||||||
|
if err := os.RemoveAll(tmpDir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "os.RemoveAll(%q): %v\n", tmpDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.Pull(ctx, url, tmpDir); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("Pull(%q): %w", url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpDir, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadArtifactFile copies a file into a temporary directory to allow for recursive diffing.
|
||||||
|
// If path is a .tar.gz or .tgz file, the archive is extracted into a temporary directory.
|
||||||
|
// Otherwise the file is copied verbatim.
|
||||||
|
func loadArtifactFile(path string) (dir string, cleanup func(), err error) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "flux-diff-artifact")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("could not create temporary directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = func() {
|
||||||
|
if err := os.RemoveAll(tmpDir); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "os.RemoveAll(%q): %v\n", tmpDir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz") {
|
||||||
|
if err := extractTo(path, tmpDir); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("os.Open(%q): %w", path, err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
name := filepath.Join(tmpDir, filepath.Base(path))
|
||||||
|
if err := copyFile(fh, name); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("os.Open(%q): %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpDir, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTo extracts the .tar.gz / .tgz archive at archivePath into the destDir directory.
|
||||||
|
func extractTo(archivePath, destDir string) error {
|
||||||
|
archiveFH, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer archiveFH.Close()
|
||||||
|
|
||||||
|
if err := tar.Untar(archiveFH, destDir); err != nil {
|
||||||
|
return fmt.Errorf("Untar(%q, %q): %w", archivePath, destDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyFile(from io.Reader, to string) error {
|
||||||
|
fh, err := os.Create(to)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("os.Create(%q): %w", to, err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(fh, from); err != nil {
|
||||||
|
return fmt.Errorf("io.Copy(%q): %w", to, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type differ interface {
|
||||||
|
// Diff compares the two local directories "to" and "from" and returns their differences, or an empty string if they are equal.
|
||||||
|
Diff(ctx context.Context, from, to string, ignorePaths []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type unifiedDiff struct{}
|
||||||
|
|
||||||
|
func (d unifiedDiff) Diff(_ context.Context, fromDir, toDir string, ignorePaths []string) (string, error) {
|
||||||
|
matcher := newMatcher(ignorePaths)
|
||||||
|
|
||||||
|
fromFiles, err := filesInDir(fromDir, matcher)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "fromFiles = %v\n", fromFiles)
|
||||||
|
|
||||||
|
toFiles, err := filesInDir(toDir, matcher)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "toFiles = %v\n", toFiles)
|
||||||
|
|
||||||
|
allFiles := fromFiles.Union(toFiles)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
for _, relPath := range allFiles.Elements() {
|
||||||
|
diff, err := d.diffFiles(fromDir, toDir, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(&sb, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d unifiedDiff) diffFiles(fromDir, toDir, relPath string) (string, error) {
|
||||||
|
fromPath := filepath.Join(fromDir, relPath)
|
||||||
|
fromData, err := d.readFile(fromPath)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
return fmt.Sprintf("Only in %s: %s\n", toDir, relPath), nil
|
||||||
|
case err != nil:
|
||||||
|
return "", fmt.Errorf("readFile(%q): %w", fromPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toPath := filepath.Join(toDir, relPath)
|
||||||
|
toData, err := d.readFile(toPath)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
return fmt.Sprintf("Only in %s: %s\n", fromDir, relPath), nil
|
||||||
|
case err != nil:
|
||||||
|
return "", fmt.Errorf("readFile(%q): %w", toPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edits := myers.ComputeEdits(span.URIFromPath(fromPath), string(fromData), string(toData))
|
||||||
|
return fmt.Sprint(gotextdiff.ToUnified(fromPath, toPath, string(fromData), edits)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d unifiedDiff) readFile(path string) ([]byte, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
return io.ReadAll(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPath(path string) []string {
|
||||||
|
return strings.Split(path, string([]rune{filepath.Separator}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func filesInDir(root string, matcher gitignore.Matcher) (stringset.Set, error) {
|
||||||
|
var files stringset.Set
|
||||||
|
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("filepath.Rel(%q, %q): %w", root, path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matcher.Match(splitPath(relPath), d.IsDir()) {
|
||||||
|
if d.IsDir() {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.Type().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files.Add(relPath)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalDiff implements the differ interface using an external diff command.
|
||||||
|
type externalDiff struct{}
|
||||||
|
|
||||||
|
// externalDiffVar is the environment variable users can use to overwrite the external diff command.
|
||||||
|
const externalDiffVar = "FLUX_EXTERNAL_DIFF"
|
||||||
|
|
||||||
|
func (externalDiff) Diff(ctx context.Context, fromDir, toDir string, ignorePaths []string) (string, error) {
|
||||||
|
cmdline := os.Getenv(externalDiffVar)
|
||||||
|
if cmdline == "" {
|
||||||
|
return "", fmt.Errorf("the required %q environment variable is unset", externalDiffVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
args, err := shlex.Split(cmdline)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("shlex.Split(%q): %w", cmdline, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var executable string
|
||||||
|
executable, args = args[0], args[1:]
|
||||||
|
|
||||||
|
for _, path := range ignorePaths {
|
||||||
|
args = append(args, "--exclude", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, fromDir, toDir)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, executable, args...)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
|
// exit code 1 only means there was a difference => ignore
|
||||||
|
} else if err != nil {
|
||||||
|
return "", fmt.Errorf("executing %q: %w", executable, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dyffBuiltin implements the differ interface using `dyff`, a semantic diff for YAML documents.
|
||||||
|
type dyffBuiltin struct {
|
||||||
|
opts []dyff.CompareOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d dyffBuiltin) Diff(ctx context.Context, fromDir, toDir string, _ []string) (string, error) {
|
||||||
|
fromFile, err := ytbx.LoadDirectory(fromDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ytbx.LoadDirectory(%q): %w", fromDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toFile, err := ytbx.LoadDirectory(toDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ytbx.LoadDirectory(%q): %w", toDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, err := dyff.CompareInputFiles(fromFile, toFile, d.opts...)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("dyff.CompareInputFiles(): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(report.Diffs) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
if err := printers.NewDyffPrinter().Print(&buf, report); err != nil {
|
||||||
|
return "", fmt.Errorf("formatting dyff report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// differFlag implements pflag.Value for choosing a diffing implementation.
|
||||||
|
type differFlag struct {
|
||||||
|
options map[string]differ
|
||||||
|
description map[string]string
|
||||||
|
value string
|
||||||
|
differ
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *differFlag) Set(s string) error {
|
||||||
|
d, ok := f.options[s]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid value: %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.value = s
|
||||||
|
f.differ = d
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *differFlag) String() string {
|
||||||
|
return f.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *differFlag) Type() string {
|
||||||
|
keys := maps.Keys(f.options)
|
||||||
|
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
return strings.Join(keys, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *differFlag) usage() string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprint(&b, "how the diff is generated:")
|
||||||
|
|
||||||
|
keys := maps.Keys(f.options)
|
||||||
|
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
fmt.Fprintf(&b, "\n %q: %s", key, f.description[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -65,6 +68,7 @@ func TestDiffArtifact(t *testing.T) {
|
||||||
argsTpl string
|
argsTpl string
|
||||||
pushFile string
|
pushFile string
|
||||||
diffFile string
|
diffFile string
|
||||||
|
diffName string
|
||||||
assert assertFunc
|
assert assertFunc
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|
@ -75,14 +79,50 @@ func TestDiffArtifact(t *testing.T) {
|
||||||
diffFile: "./testdata/diff-artifact/deployment.yaml",
|
diffFile: "./testdata/diff-artifact/deployment.yaml",
|
||||||
assert: assertGoldenFile("testdata/diff-artifact/success.golden"),
|
assert: assertGoldenFile("testdata/diff-artifact/success.golden"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "create unified diff output by default",
|
||||||
|
url: "oci://%s/podinfo:2.0.0",
|
||||||
|
argsTpl: "diff artifact %s --path=%s",
|
||||||
|
pushFile: "./testdata/diff-artifact/deployment.yaml",
|
||||||
|
diffFile: "./testdata/diff-artifact/deployment-diff.yaml",
|
||||||
|
diffName: "deployment.yaml",
|
||||||
|
assert: assert(
|
||||||
|
assertErrorIs(ErrDiffArtifactChanged),
|
||||||
|
assertRegexp(`(?m)^- cpu: 1000m$`),
|
||||||
|
assertRegexp(`(?m)^\+ cpu: 2000m$`),
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "should fail if there is a diff",
|
name: "should fail if there is a diff",
|
||||||
url: "oci://%s/podinfo:2.0.0",
|
url: "oci://%s/podinfo:2.0.0",
|
||||||
argsTpl: "diff artifact %s --path=%s",
|
argsTpl: "diff artifact %s --path=%s",
|
||||||
pushFile: "./testdata/diff-artifact/deployment.yaml",
|
pushFile: "./testdata/diff-artifact/deployment.yaml",
|
||||||
diffFile: "./testdata/diff-artifact/deployment-diff.yaml",
|
diffFile: "./testdata/diff-artifact/deployment-diff.yaml",
|
||||||
assert: assertError("the remote artifact contents differs from the local one"),
|
diffName: "only-local.yaml",
|
||||||
|
assert: assert(
|
||||||
|
assertErrorIs(ErrDiffArtifactChanged),
|
||||||
|
assertRegexp(`(?m)^Only in [^:]+: deployment.yaml$`),
|
||||||
|
assertRegexp(`(?m)^Only in [^:]+: only-local.yaml$`),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "semantic diff using dyff",
|
||||||
|
url: "oci://%s/podinfo:2.0.0",
|
||||||
|
argsTpl: "diff artifact %s --path=%s --differ=dyff",
|
||||||
|
pushFile: "./testdata/diff-artifact/deployment.yaml",
|
||||||
|
diffFile: "./testdata/diff-artifact/deployment-diff.yaml",
|
||||||
|
diffName: "deployment.yaml",
|
||||||
|
assert: assert(
|
||||||
|
assertErrorIs(ErrDiffArtifactChanged),
|
||||||
|
assertRegexp(`(?m)^spec.template.spec.containers.podinfod.resources.limits.cpu$`),
|
||||||
|
assertRegexp(`(?m)^ ± value change$`),
|
||||||
|
assertRegexp(`(?m)^ - 1000m$`),
|
||||||
|
assertRegexp(`(?m)^ \+ 2000m$`),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Attention: tests do not spawn a new process when executing commands.
|
||||||
|
// That means that the --differ flag remains set to "dyff" for
|
||||||
|
// subsequent tests.
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := ctrl.SetupSignalHandler()
|
ctx := ctrl.SetupSignalHandler()
|
||||||
|
|
@ -99,11 +139,38 @@ func TestDiffArtifact(t *testing.T) {
|
||||||
t.Fatalf(fmt.Errorf("failed to push image: %w", err).Error())
|
t.Fatalf(fmt.Errorf("failed to push image: %w", err).Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diffFile := tt.diffFile
|
||||||
|
if tt.diffName != "" {
|
||||||
|
diffFile = makeTempFile(t, tt.diffFile, tt.diffName)
|
||||||
|
}
|
||||||
|
|
||||||
cmd := cmdTestCase{
|
cmd := cmdTestCase{
|
||||||
args: fmt.Sprintf(tt.argsTpl, tt.url, tt.diffFile),
|
args: fmt.Sprintf(tt.argsTpl, tt.url, diffFile),
|
||||||
assert: tt.assert,
|
assert: tt.assert,
|
||||||
}
|
}
|
||||||
cmd.runTestCmd(t)
|
cmd.runTestCmd(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeTempFile(t *testing.T, source, basename string) string {
|
||||||
|
path := filepath.Join(t.TempDir(), basename)
|
||||||
|
out, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
in, err := os.Open(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,13 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -33,7 +35,7 @@ import (
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/mattn/go-shellwords"
|
"github.com/mattn/go-shellwords"
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
|
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
|
@ -115,7 +117,7 @@ func (m *testEnvKubeManager) CreateObjects(clientObjects []*unstructured.Unstruc
|
||||||
obj.SetResourceVersion(createObj.GetResourceVersion())
|
obj.SetResourceVersion(createObj.GetResourceVersion())
|
||||||
err = m.client.Status().Update(context.Background(), obj)
|
err = m.client.Status().Update(context.Background(), obj)
|
||||||
// Updating status of static objects results in not found error.
|
// Updating status of static objects results in not found error.
|
||||||
if err != nil && !errors.IsNotFound(err) {
|
if err != nil && !k8serrors.IsNotFound(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -272,6 +274,15 @@ func assertError(expected string) assertFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertErrorIs(want error) assertFunc {
|
||||||
|
return func(_ string, got error) error {
|
||||||
|
if errors.Is(got, want) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("Expected error '%v' but got '%v'", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Expect the command to succeed with the expected test output.
|
// Expect the command to succeed with the expected test output.
|
||||||
func assertGoldenValue(expected string) assertFunc {
|
func assertGoldenValue(expected string) assertFunc {
|
||||||
return assert(
|
return assert(
|
||||||
|
|
@ -328,6 +339,17 @@ func assertGoldenTemplateFile(goldenFile string, templateValues map[string]strin
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertRegexp(expected string) assertFunc {
|
||||||
|
re := regexp.MustCompile(expected)
|
||||||
|
|
||||||
|
return func(output string, _ error) error {
|
||||||
|
if !re.MatchString(output) {
|
||||||
|
return fmt.Errorf("Output does not match regular expression:\nOutput:\n%s\n\nRegular expression:\n%s", output, expected)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type TestClusterMode int
|
type TestClusterMode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ metadata:
|
||||||
labels:
|
labels:
|
||||||
kustomize.toolkit.fluxcd.io/name: podinfo
|
kustomize.toolkit.fluxcd.io/name: podinfo
|
||||||
kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }}
|
kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }}
|
||||||
name: podinfo-diff
|
name: podinfo
|
||||||
namespace: default
|
namespace: default
|
||||||
spec:
|
spec:
|
||||||
minReadySeconds: 3
|
minReadySeconds: 3
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ spec:
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 2000m
|
cpu: 1000m
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
requests:
|
requests:
|
||||||
cpu: 100m
|
cpu: 100m
|
||||||
|
|
|
||||||
6
go.mod
6
go.mod
|
|
@ -6,6 +6,7 @@ go 1.22.4
|
||||||
replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
|
replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
bitbucket.org/creachadair/stringset v0.0.14
|
||||||
github.com/Masterminds/semver/v3 v3.2.1
|
github.com/Masterminds/semver/v3 v3.2.1
|
||||||
github.com/ProtonMail/go-crypto v1.0.0
|
github.com/ProtonMail/go-crypto v1.0.0
|
||||||
github.com/cyphar/filepath-securejoin v0.3.1
|
github.com/cyphar/filepath-securejoin v0.3.1
|
||||||
|
|
@ -37,6 +38,7 @@ require (
|
||||||
github.com/gonvenience/ytbx v1.4.4
|
github.com/gonvenience/ytbx v1.4.4
|
||||||
github.com/google/go-cmp v0.6.0
|
github.com/google/go-cmp v0.6.0
|
||||||
github.com/google/go-containerregistry v0.20.2
|
github.com/google/go-containerregistry v0.20.2
|
||||||
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2
|
github.com/hashicorp/go-cleanhttp v0.5.2
|
||||||
github.com/homeport/dyff v1.7.1
|
github.com/homeport/dyff v1.7.1
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||||
|
|
@ -50,6 +52,7 @@ require (
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/spf13/pflag v1.0.5
|
||||||
github.com/theckman/yacspin v0.13.12
|
github.com/theckman/yacspin v0.13.12
|
||||||
golang.org/x/crypto v0.26.0
|
golang.org/x/crypto v0.26.0
|
||||||
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
||||||
golang.org/x/term v0.23.0
|
golang.org/x/term v0.23.0
|
||||||
golang.org/x/text v0.17.0
|
golang.org/x/text v0.17.0
|
||||||
k8s.io/api v0.31.0
|
k8s.io/api v0.31.0
|
||||||
|
|
@ -144,7 +147,6 @@ require (
|
||||||
github.com/google/go-github/v64 v64.0.0 // indirect
|
github.com/google/go-github/v64 v64.0.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/gofuzz v1.2.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/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/handlers v1.5.2 // indirect
|
github.com/gorilla/handlers v1.5.2 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
|
|
@ -157,6 +159,7 @@ require (
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
|
github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
|
||||||
|
github.com/hexops/gotextdiff v1.0.3
|
||||||
github.com/imdario/mergo v0.3.16 // indirect
|
github.com/imdario/mergo v0.3.16 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
|
|
@ -232,7 +235,6 @@ require (
|
||||||
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||||
go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect
|
go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
|
||||||
golang.org/x/net v0.28.0 // indirect
|
golang.org/x/net v0.28.0 // indirect
|
||||||
golang.org/x/oauth2 v0.22.0 // indirect
|
golang.org/x/oauth2 v0.22.0 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/sync v0.8.0 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,3 +1,5 @@
|
||||||
|
bitbucket.org/creachadair/stringset v0.0.14 h1:t1ejQyf8utS4GZV/4fM+1gvYucggZkfhb+tMobDxYOE=
|
||||||
|
bitbucket.org/creachadair/stringset v0.0.14/go.mod h1:Ej8fsr6rQvmeMDf6CCWMWGb14H9mz8kmDgPPTdiVT0w=
|
||||||
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
|
code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y=
|
||||||
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
|
code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
|
||||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
|
|
@ -323,6 +325,8 @@ github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGN
|
||||||
github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU=
|
github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4=
|
github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/homeport/dyff v1.7.1 h1:B3KJUtnU53H2UryxGcfYKQPrde8VjjbwlHZbczH3giQ=
|
github.com/homeport/dyff v1.7.1 h1:B3KJUtnU53H2UryxGcfYKQPrde8VjjbwlHZbczH3giQ=
|
||||||
github.com/homeport/dyff v1.7.1/go.mod h1:iLe5b3ymc9xmHZNuJlNVKERE8L2isQMBLxFiTXcwZY0=
|
github.com/homeport/dyff v1.7.1/go.mod h1:iLe5b3ymc9xmHZNuJlNVKERE8L2isQMBLxFiTXcwZY0=
|
||||||
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue