diff --git a/cmd/flux/diff_artifact.go b/cmd/flux/diff_artifact.go index edfd93827b..af7603c485 100644 --- a/cmd/flux/diff_artifact.go +++ b/cmd/flux/diff_artifact.go @@ -17,24 +17,48 @@ limitations under the License. package main import ( + "bytes" "context" + "errors" "fmt" + "io" + "io/fs" "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "bitbucket.org/creachadair/stringset" oci "github.com/fluxcd/pkg/oci/client" + "github.com/fluxcd/pkg/tar" 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" + "golang.org/x/exp/maps" "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{ - Use: "artifact", + Use: "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 -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, + Args: cobra.RangeArgs(1, 2), } type diffArtifactFlags struct { @@ -42,6 +66,8 @@ type diffArtifactFlags struct { creds string provider flags.SourceOCIProvider ignorePaths []string + brief bool + differ *differFlag } var diffArtifactArgs = newDiffArtifactArgs() @@ -49,34 +75,58 @@ var diffArtifactArgs = newDiffArtifactArgs() func newDiffArtifactArgs() diffArtifactFlags { return diffArtifactFlags{ 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() { - 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 [:] if --provider is generic") 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().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) } func diffArtifactCmdRun(cmd *cobra.Command, args []string) error { + var from, to string + if len(args) < 1 { return fmt.Errorf("artifact URL is required") } - ociURL := args[0] + from = args[0] - if diffArtifactArgs.path == "" { - return fmt.Errorf("invalid path %q", diffArtifactArgs.path) - } + switch { + case len(args) >= 2: + to = args[1] - url, err := oci.ParseArtifactURL(ociURL) - if err != nil { - return err - } + case diffArtifactArgs.path != "": + // for backwards compatibility + to = diffArtifactArgs.path - if _, err := os.Stat(diffArtifactArgs.path); err != nil { - return fmt.Errorf("invalid path '%s', must point to an existing directory or file", diffArtifactArgs.path) + default: + return errors.New("a second artifact is required") } 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) } - if err := ociClient.LoginWithProvider(ctx, url, ociProvider); err != nil { - return fmt.Errorf("error during login with provider: %w", err) + if url, err := oci.ParseArtifactURL(from); err == nil { + 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) + } + } + } + + diff, err := diffArtifact(ctx, ociClient, from, to, diffArtifactArgs) + if err != nil { + return err + } + + 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 err := ociClient.Diff(ctx, url, diffArtifactArgs.path, diffArtifactArgs.ignorePaths); err != nil { + 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) + } - logger.Successf("no changes detected") 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() +} diff --git a/cmd/flux/diff_artifact_test.go b/cmd/flux/diff_artifact_test.go index 05e2991538..fca6443ce3 100644 --- a/cmd/flux/diff_artifact_test.go +++ b/cmd/flux/diff_artifact_test.go @@ -22,6 +22,9 @@ package main import ( "context" "fmt" + "io" + "os" + "path/filepath" "testing" "time" @@ -65,6 +68,7 @@ func TestDiffArtifact(t *testing.T) { argsTpl string pushFile string diffFile string + diffName string assert assertFunc }{ { @@ -75,14 +79,50 @@ func TestDiffArtifact(t *testing.T) { diffFile: "./testdata/diff-artifact/deployment.yaml", 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", 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", - 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() @@ -99,11 +139,38 @@ func TestDiffArtifact(t *testing.T) { 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{ - args: fmt.Sprintf(tt.argsTpl, tt.url, tt.diffFile), + args: fmt.Sprintf(tt.argsTpl, tt.url, diffFile), assert: tt.assert, } 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 +} diff --git a/cmd/flux/main_test.go b/cmd/flux/main_test.go index b13bb6ce73..87ad0a40d1 100644 --- a/cmd/flux/main_test.go +++ b/cmd/flux/main_test.go @@ -20,11 +20,13 @@ import ( "bufio" "bytes" "context" + "errors" "flag" "fmt" "io" "os" "path/filepath" + "regexp" "strings" "sync/atomic" "testing" @@ -33,7 +35,7 @@ import ( "github.com/google/go-cmp/cmp" "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" k8syaml "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/tools/clientcmd" @@ -115,7 +117,7 @@ func (m *testEnvKubeManager) CreateObjects(clientObjects []*unstructured.Unstruc obj.SetResourceVersion(createObj.GetResourceVersion()) err = m.client.Status().Update(context.Background(), obj) // Updating status of static objects results in not found error. - if err != nil && !errors.IsNotFound(err) { + if err != nil && !k8serrors.IsNotFound(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. func assertGoldenValue(expected string) assertFunc { 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 const ( diff --git a/cmd/flux/testdata/diff-artifact/deployment-diff.yaml b/cmd/flux/testdata/diff-artifact/deployment-diff.yaml index 350d4c1b24..3910da6ac7 100644 --- a/cmd/flux/testdata/diff-artifact/deployment-diff.yaml +++ b/cmd/flux/testdata/diff-artifact/deployment-diff.yaml @@ -4,7 +4,7 @@ metadata: labels: kustomize.toolkit.fluxcd.io/name: podinfo kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }} - name: podinfo-diff + name: podinfo namespace: default spec: minReadySeconds: 3 diff --git a/cmd/flux/testdata/diff-artifact/deployment.yaml b/cmd/flux/testdata/diff-artifact/deployment.yaml index 3910da6ac7..dc7584c58b 100644 --- a/cmd/flux/testdata/diff-artifact/deployment.yaml +++ b/cmd/flux/testdata/diff-artifact/deployment.yaml @@ -71,7 +71,7 @@ spec: timeoutSeconds: 5 resources: limits: - cpu: 2000m + cpu: 1000m memory: 512Mi requests: cpu: 100m diff --git a/go.mod b/go.mod index 86b00a7c5f..222ecf9cbc 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ go 1.22.4 replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1 require ( + bitbucket.org/creachadair/stringset v0.0.14 github.com/Masterminds/semver/v3 v3.2.1 github.com/ProtonMail/go-crypto v1.0.0 github.com/cyphar/filepath-securejoin v0.3.1 @@ -37,6 +38,7 @@ require ( github.com/gonvenience/ytbx v1.4.4 github.com/google/go-cmp v0.6.0 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/homeport/dyff v1.7.1 github.com/lucasb-eyer/go-colorful v1.2.0 @@ -50,6 +52,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/theckman/yacspin v0.13.12 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/text v0.17.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-querystring v1.1.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/gorilla/handlers v1.5.2 // 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/golang-lru/arc/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/inconshreveable/mousetrap v1.1.0 // 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/proto/otlp v1.3.1 // 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/oauth2 v0.22.0 // indirect golang.org/x/sync v0.8.0 // indirect diff --git a/go.sum b/go.sum index 53c00fd159..c821635333 100644 --- a/go.sum +++ b/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/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= 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/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= 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/go.mod h1:iLe5b3ymc9xmHZNuJlNVKERE8L2isQMBLxFiTXcwZY0= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=