diff --git a/cmd/dyff/dyff b/cmd/dyff/dyff new file mode 100755 index 0000000..b303534 Binary files /dev/null and b/cmd/dyff/dyff differ diff --git a/go.mod b/go.mod index 42266a2..c642db9 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/mitchellh/hashstructure v1.1.0 github.com/onsi/ginkgo/v2 v2.8.3 github.com/onsi/gomega v1.27.1 + github.com/pkg/errors v0.9.1 github.com/sergi/go-diff v1.3.1 github.com/spf13/cobra v1.6.1 github.com/texttheater/golang-levenshtein v1.0.1 diff --git a/go.sum b/go.sum index 4e30da0..8033523 100644 --- a/go.sum +++ b/go.sum @@ -55,6 +55,7 @@ github.com/onsi/ginkgo/v2 v2.8.3/go.mod h1:6OaUA8BCi0aZfmzYT/q9AacwTzDpNbxILUT+T github.com/onsi/gomega v1.27.1 h1:rfztXRbg6nv/5f+Raen9RcGoSecHIFgBBLQK3Wdj754= github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/applyPatch.go b/internal/cmd/applyPatch.go new file mode 100644 index 0000000..2dcf84f --- /dev/null +++ b/internal/cmd/applyPatch.go @@ -0,0 +1,136 @@ +// Copyright © 2019 The Homeport Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package cmd + +import ( + "bufio" + "bytes" + "fmt" + "github.com/gonvenience/ytbx" + "github.com/homeport/dyff/pkg/dyff" + "github.com/pkg/errors" + "github.com/spf13/cobra" + yamlv3 "gopkg.in/yaml.v3" + "io" + "os" +) + +type applyPatchCmdOptions struct { + output string + indent uint +} + +var applyPatchCmdSettings applyPatchCmdOptions + +// yamlCmd represents the yaml command +var applyPatchCmd = &cobra.Command{ + Use: "apply-patch [flags] ", + Aliases: []string{"apply", "ap"}, + Args: cobra.MinimumNArgs(2), + Short: "Apply a YAML patch produced by 'between' to a file (use '-' for stdin)", + Long: `Applies a YAML patch created by the 'between' command (see between help for details) to a YAML file or stdin and writes to an output file or stdout.`, + + RunE: func(cmd *cobra.Command, args []string) error { + patchb, err := os.ReadFile(args[0]) + if err != nil { + return errors.Wrap(err, "error reading patch file") + } + + var patch []dyff.PatchOp + err = yamlv3.Unmarshal(patchb, &patch) + if err != nil { + return errors.Wrap(err, "error unmarshaling patch (is patch file corrupted?)") + } + + var input []byte + if args[1] == "-" { + in, err := io.ReadAll(os.Stdin) + if err != nil { + return errors.Wrap(err, "error reading from stdin") + } + input = in + } else { + in, err := os.ReadFile(args[1]) + if err != nil { + return errors.Wrap(err, "error reading input file") + } + input = in + } + + inputdocs, err := ytbx.LoadYAMLDocuments(input) + if err != nil { + return errors.Wrap(err, "error unmarshaling input (is it valid YAML?)") + } + + if len(inputdocs) == 0 { + return fmt.Errorf("no YAML documents found in input") + } + + if len(inputdocs) > 1 { + fmt.Fprintf(os.Stderr, "warning: multiple yaml docs found in input, applying patch to the first doc only") + } + + err = dyff.ApplyPatch(inputdocs[0], patch) + if err != nil { + return errors.Wrap(err, "error applying patch") + } + + var b bytes.Buffer + bw := bufio.NewWriter(&b) + yenc := yamlv3.NewEncoder(bw) + yenc.SetIndent(int(applyPatchCmdSettings.indent)) + + err = yenc.Encode(inputdocs[0]) + if err != nil { + return errors.Wrap(err, "error marshaling yaml output") + } + err = yenc.Close() + if err != nil { + return errors.Wrap(err, "error flushing yaml encoder") + } + err = bw.Flush() + if err != nil { + return errors.Wrap(err, "error flushing output buffer") + } + + out := b.Bytes() + + if applyPatchCmdSettings.output == "-" { + fmt.Printf("%s\v", out) + } else { + err := os.WriteFile(applyPatchCmdSettings.output, out, 0644) + if err != nil { + return errors.Wrap(err, "error writing output file") + } + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(applyPatchCmd) + + applyPatchCmd.Flags().SortFlags = false + + applyPatchCmd.Flags().StringVarP(&applyPatchCmdSettings.output, "output", "o", "-", "output path (use - for stdout)") + applyPatchCmd.Flags().UintVarP(&applyPatchCmdSettings.indent, "indent", "i", 2, "YAML output indent size (in spaces)") +} diff --git a/internal/cmd/between.go b/internal/cmd/between.go index 6f78035..8ec4510 100644 --- a/internal/cmd/between.go +++ b/internal/cmd/between.go @@ -21,11 +21,13 @@ package cmd import ( + "fmt" "github.com/gonvenience/wrap" "github.com/gonvenience/ytbx" - "github.com/spf13/cobra" - "github.com/homeport/dyff/pkg/dyff" + "github.com/spf13/cobra" + yamlv3 "gopkg.in/yaml.v3" + "os" ) type betweenCmdOptions struct { @@ -34,6 +36,8 @@ type betweenCmdOptions struct { chroot string chrootFrom string chrootTo string + genPatch bool + patchfile string } var betweenCmdSettings betweenCmdOptions @@ -109,7 +113,31 @@ types are: YAML (http://yaml.org/) and JSON (http://json.org/). report = report.ExcludeRegexp(reportOptions.excludeRegexps...) } - return writeReport(cmd, report) + if err := writeReport(cmd, report); err != nil { + return err + } + + if betweenCmdSettings.genPatch { + p, err := dyff.GeneratePatch(&report) + if err != nil { + return wrap.Errorf(err, "error generating patch") + } + pm, err := yamlv3.Marshal(p) + if err != nil { + return wrap.Errorf(err, "error marshaling patch") + } + + if betweenCmdSettings.patchfile == "-" { + fmt.Printf("%s\n", pm) + } else { + err := os.WriteFile(betweenCmdSettings.patchfile, pm, 0644) + if err != nil { + return wrap.Errorf(err, "error writing patch file") + } + } + } + + return nil }, } @@ -126,4 +154,6 @@ func init() { betweenCmd.Flags().StringVar(&betweenCmdSettings.chrootFrom, "chroot-of-from", "", "only change the root level of the from input file") betweenCmd.Flags().StringVar(&betweenCmdSettings.chrootTo, "chroot-of-to", "", "only change the root level of the to input file") betweenCmd.Flags().BoolVar(&betweenCmdSettings.translateListToDocuments, "chroot-list-to-documents", false, "in case the change root points to a list, treat this list as a set of documents and not as the list itself") + betweenCmd.Flags().BoolVar(&betweenCmdSettings.genPatch, "generate-patch", false, "generate patch") + betweenCmd.Flags().StringVar(&betweenCmdSettings.patchfile, "patch-file", "patch.txt", "patch output filename (use - for stdout)") } diff --git a/internal/cmd/cmds_test.go b/internal/cmd/cmds_test.go index 289148c..6c06d6c 100644 --- a/internal/cmd/cmds_test.go +++ b/internal/cmd/cmds_test.go @@ -22,7 +22,10 @@ package cmd_test import ( "fmt" + dyff2 "github.com/homeport/dyff/pkg/dyff" + yamlv3 "gopkg.in/yaml.v3" "os" + "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -579,6 +582,88 @@ spec.replicas (Deployment/default/test) Expect(err).ToNot(HaveOccurred()) Expect(out).To(BeEquivalentTo("\n")) }) + + It("should create a patch file when requested", func() { + from := createTestFile(`{"list":[{"aaa":"bbb","name":"one"}]}`) + defer os.Remove(from) + + to := createTestFile(`{"list":[{"aaa":"bbb","name":"two"}]}`) + defer os.Remove(to) + + _, err := dyff("between", "--generate-patch", "--patch-file=patch.txt", from, to) + Expect(err).ToNot(HaveOccurred()) + defer os.Remove("patch.txt") + + out, err := os.ReadFile("patch.txt") + Expect(err).ToNot(HaveOccurred()) + + var p []dyff2.PatchOp + err = yamlv3.Unmarshal(out, &p) + Expect(err).ToNot(HaveOccurred()) + + Expect(p).To(HaveLen(2)) + Expect(p[0].Op).To(Equal("remove")) + Expect(p[1].Op).To(Equal("add")) + }) + }) + + Context("apply-patch command", func() { + It("should apply a patch successfully and write output to a file", func() { + type testdoc struct { + Foo string + } + + from := createTestFile(`{"foo":"bar"}`) + defer os.Remove(from) + + patch := createTestFile(`- op: replace + fromkind: scalar + tokind: scalar + path: /foo + tovalue: abc + fromvalue: bar`) + defer os.Remove(patch) + + _, err := dyff("apply-patch", "--output=out.yml", patch, from) + Expect(err).ToNot(HaveOccurred()) + defer os.Remove("out.yml") + + d, err := os.ReadFile("out.yml") + Expect(err).ToNot(HaveOccurred()) + + var td testdoc + err = yamlv3.Unmarshal(d, &td) + Expect(err).ToNot(HaveOccurred()) + + Expect(td.Foo).To(Equal("abc")) + }) + + It("should apply a patch successfully and write output to stdout if requested", func() { + type testdoc struct { + Foo string + } + + from := createTestFile(`{"foo":"bar"}`) + defer os.Remove(from) + + patch := createTestFile(`- op: replace + fromkind: scalar + tokind: scalar + path: /foo + tovalue: abc + fromvalue: bar`) + defer os.Remove(patch) + + out, err := dyff("apply-patch", "--output=-", patch, from) + Expect(err).ToNot(HaveOccurred()) + + var td testdoc + err = yamlv3.Unmarshal([]byte(strings.TrimSpace(out)), &td) + Expect(err).ToNot(HaveOccurred()) + + Expect(td.Foo).To(Equal("abc")) + }) + }) Context("last-applied command", func() { diff --git a/pkg/dyff/patch.go b/pkg/dyff/patch.go new file mode 100644 index 0000000..ad91c3f --- /dev/null +++ b/pkg/dyff/patch.go @@ -0,0 +1,150 @@ +package dyff + +import ( + "fmt" + "github.com/gonvenience/ytbx" + yamlv3 "gopkg.in/yaml.v3" +) + +var opMap = map[rune]string{ + ADDITION: "add", + REMOVAL: "remove", + MODIFICATION: "replace", + ORDERCHANGE: "reorder", +} + +var kindMap = map[yamlv3.Kind]string{ + yamlv3.DocumentNode: "document", + yamlv3.SequenceNode: "sequence", + yamlv3.MappingNode: "mapping", + yamlv3.ScalarNode: "scalar", + yamlv3.AliasNode: "alias", +} + +type PatchOp struct { + Op string + FromKind string + ToKind string + Path string + ToValue yamlv3.Node + FromValue yamlv3.Node +} + +func GeneratePatch(r *Report) ([]PatchOp, error) { + var out []PatchOp + + for _, d := range r.Diffs { + for _, dd := range d.Details { + po := PatchOp{ + Op: opMap[dd.Kind], + Path: d.Path.String(), + } + if dd.From != nil { + po.FromKind = kindMap[dd.From.Kind] + po.FromValue = *dd.From + } + if dd.To != nil { + po.ToKind = kindMap[dd.To.Kind] + po.ToValue = *dd.To + } + out = append(out, po) + } + } + + return out, nil +} + +func ApplyPatch(input *yamlv3.Node, patch []PatchOp) error { + for i, op := range patch { + n, err := ytbx.Grab(input, op.Path) + if err != nil { + return fmt.Errorf("offset %d: error getting node at path: %v: %w", i, op.Path, err) + } + switch op.Op { + case "add": + n.Content = append(n.Content, op.ToValue.Content...) + case "remove": + err := rmNode(n, &op.FromValue) + if err != nil { + return fmt.Errorf("offset %d: error removing node: %v: %w", i, op.Path, err) + } + case "replace": + if !matchNodes(n, &op.FromValue) { + return fmt.Errorf("offset %d: error replacing value: from value doesn't match: %v (wanted %v)", i, n.Value, op.FromValue.Value) + } + n.Value = op.ToValue.Value + case "reorder": + if n.Kind != yamlv3.SequenceNode { + return fmt.Errorf("offset %d: incorrect kind of node for reorder (wanted Sequence): %v", i, n.Kind) + } + found, idx := matchSubslice(n.Content, op.FromValue.Content) + if !found { + return fmt.Errorf("offset %d: error reordering: from value not found at path: %v", i, op.Path) + } + for i := range op.ToValue.Content { + n.Content[idx+i] = op.ToValue.Content[i] + } + default: + return fmt.Errorf("offset %d: unknown op: %v", i, op.Op) + } + } + return nil +} + +// rmNode finds from in n and removes it +func rmNode(n *yamlv3.Node, from *yamlv3.Node) error { + found, idx := matchSubslice(n.Content, from.Content) + if !found { + return fmt.Errorf("from node not found: %+v", from) + } + n.Content = append(n.Content[:idx], n.Content[idx+len(from.Content):]...) + return nil +} + +// matchSubslice checks if a contains subslice b, returning index of b if found +func matchSubslice(a, b []*yamlv3.Node) (bool, int) { + if len(b) > len(a) { + return false, 0 + } +Loop: + for i := range a { + if matchNodes(a[i], b[0]) { + for j := range b[1:] { + if !matchNodes(a[i+j+1], b[j+1]) { + continue Loop + } + } + return true, i + } + } + return false, 0 +} + +// matchNodes determines whether a and b are equal +func matchNodes(a, b *yamlv3.Node) bool { + if a.Kind != b.Kind { + return false + } + if len(a.Content) != len(b.Content) { + return false + } + switch a.Kind { + case yamlv3.ScalarNode: + return a.Value == b.Value + case yamlv3.DocumentNode: + fallthrough + case yamlv3.MappingNode: + fallthrough + case yamlv3.SequenceNode: + for i := range a.Content { + if !matchNodes(a.Content[i], b.Content[i]) { + return false + } + } + return true + case yamlv3.AliasNode: + return matchNodes(a.Alias, b.Alias) + default: + panic(fmt.Sprintf("unknown node kind: %v", a.Kind)) + } +} diff --git a/pkg/dyff/patch_test.go b/pkg/dyff/patch_test.go new file mode 100644 index 0000000..82e4caa --- /dev/null +++ b/pkg/dyff/patch_test.go @@ -0,0 +1,171 @@ +package dyff_test + +import ( + "github.com/gonvenience/ytbx" + "github.com/homeport/dyff/pkg/dyff" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" +) + +type testdoc struct { + Foo map[string]int + Seq []string + SeqMap []map[string]int +} + +var a = `--- +foo: + bar: 34 + bak: 27 +seq: + - a + - b + - c + - d + - 1 + - 2 + - 3 +seqmap: + - asdf: 99 + zxcvb: 10 + - qwerty: 89 +` +var b = `--- +foo: + bar: 75 + baz: 0 +seq: + - a + - c + - b + - d + - 1 + - 2 + - 3 +seqmap: + - asdf: 98 +` + +var patchYAML = `--- +- op: remove + fromkind: mapping + tokind: "" + path: /foo + tovalue: null + fromvalue: + bak: 27 +- op: add + fromkind: "" + tokind: mapping + path: /foo + tovalue: + baz: 0 + fromvalue: null +- op: replace + fromkind: scalar + tokind: scalar + path: /foo/bar + tovalue: 75 + fromvalue: 34 +- op: reorder + fromkind: sequence + tokind: sequence + path: /seq + tovalue: + - a + - c + - b + - d + - 1 + - 2 + - 3 + fromvalue: + - a + - b + - c + - d + - 1 + - 2 + - 3 +- op: remove + fromkind: sequence + tokind: "" + path: /seqmap + tovalue: null + fromvalue: + - asdf: 99 + zxcvb: 10 + - qwerty: 89 +- op: add + fromkind: "" + tokind: sequence + path: /seqmap + tovalue: + - asdf: 98 + fromvalue: null +` + +var _ = Describe("patch tests", func() { + Context("generate patch", func() { + It("should result in the correct number of patch operations", func() { + adocs, err := ytbx.LoadYAMLDocuments([]byte(a)) + Expect(err).ToNot(HaveOccurred()) + + inputA := ytbx.InputFile{ + Documents: adocs, + } + + bdocs, err := ytbx.LoadYAMLDocuments([]byte(b)) + Expect(err).ToNot(HaveOccurred()) + + inputB := ytbx.InputFile{ + Documents: bdocs, + } + + report, err := dyff.CompareInputFiles(inputA, inputB) + Expect(err).ToNot(HaveOccurred()) + + p, err := dyff.GeneratePatch(&report) + Expect(err).ToNot(HaveOccurred()) + + _, err = yaml.Marshal(&p) + Expect(err).ToNot(HaveOccurred()) + + Expect(len(p)).To(BeEquivalentTo(6)) + }) + }) + + Context("apply patch", func() { + It("should apply and result in the original YAML", func() { + adocs, err := ytbx.LoadYAMLDocuments([]byte(a)) + Expect(err).ToNot(HaveOccurred()) + + var patch []dyff.PatchOp + err = yaml.Unmarshal([]byte(patchYAML), &patch) + Expect(err).ToNot(HaveOccurred()) + + err = dyff.ApplyPatch(adocs[0], patch) + Expect(err).ToNot(HaveOccurred()) + + out, err := yaml.Marshal(adocs[0]) + Expect(err).ToNot(HaveOccurred()) + + var td testdoc + err = yaml.Unmarshal(out, &td) + Expect(err).ToNot(HaveOccurred()) + + Expect(td.Foo).To(HaveKey("bar")) + Expect(td.Foo["bar"]).To(Equal(75)) + Expect(td.Foo).To(HaveKey("baz")) + Expect(td.Foo["baz"]).To(Equal(0)) + Expect(td.Foo).NotTo(HaveKey("bak")) + Expect(td.Seq).Should(HaveLen(7)) + Expect(td.Seq[1]).To(Equal("c")) + Expect(td.Seq[2]).To(Equal("b")) + Expect(td.SeqMap).Should(HaveLen(1)) + Expect(td.SeqMap[0]).Should(HaveKey("asdf")) + Expect(td.SeqMap[0]["asdf"]).To(Equal(98)) + }) + }) +})