package main import ( "fmt" "io" "os" "path/filepath" "strings" "github.com/go-test/deep" "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/yaml" ) type diffSource string const ( diffSourceLeft diffSource = "left" diffSourceRight diffSource = "right" ) var ( rootCmd = &cobra.Command{ Use: "diff-yamls dir-with-yamls/ dir-with-yamls/", Short: "Print any diff between the given directories", Long: `Similar to the 'diff' command, but file contents are compared after being parsed as a set of Kubernetes manifests.`, Args: cobra.ExactArgs(2), Run: run, } ) func run(cmd *cobra.Command, args []string) { left := args[0] right := args[1] leftYamls, err := globYamlFilenames(left) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } rightYamls, err := globYamlFilenames(right) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } exitCode := 0 onlyInLeft := leftYamls.Difference(rightYamls) if onlyInLeft.Len() > 0 { exitCode = 1 for _, f := range sets.List(onlyInLeft) { fmt.Fprintf(os.Stderr, "Only in %s: %s\n", left, f) } } onlyInRight := rightYamls.Difference(leftYamls) if onlyInRight.Len() > 0 { exitCode = 1 for _, f := range sets.List(onlyInRight) { fmt.Fprintf(os.Stderr, "Only in %s: %s\n", right, f) } } inBoth := leftYamls.Intersection(rightYamls) for _, f := range sets.List(inBoth) { leftPath := filepath.Join(left, f) leftNodes, err := readManifest(leftPath) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } rightPath := filepath.Join(right, f) rightNodes, err := readManifest(rightPath) if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } ps := pairs{} for _, node := range leftNodes { if err := ps.add(node, diffSourceLeft); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } } for _, node := range rightNodes { if err := ps.add(node, diffSourceRight); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } } for _, p := range ps.list { switch { case p.left != nil && p.right == nil: fmt.Fprintf(os.Stderr, "Only in %s: %s\n", leftPath, p.left.getID()) exitCode = 1 case p.left == nil && p.right != nil: fmt.Fprintf(os.Stderr, "Only in %s: %s\n", rightPath, p.right.getID()) exitCode = 1 default: diff := deep.Equal(p.left, p.right) if diff != nil { id := p.left.getID() fmt.Fprintf(os.Stderr, "< %s %s\n", id, leftPath) fmt.Fprintf(os.Stderr, "> %s %s\n", id, rightPath) for _, d := range diff { fmt.Fprintf(os.Stderr, "%s\n", d) } exitCode = 1 } } } } os.Exit(exitCode) } func globYamlFilenames(dir string) (sets.Set[string], error) { matches, err := filepath.Glob(filepath.Join(dir, "*.yaml")) if err != nil { return nil, err } set := sets.Set[string]{} for _, f := range matches { set.Insert(filepath.Base(f)) } return set, nil } type resource map[string]any type meta struct { apiVersion string kind string name string namespace string } func (res resource) getMeta() meta { if len(res) == 0 { return meta{} } m := meta{} apiVersion, _ := res["apiVersion"].(string) m.apiVersion = apiVersion kind, _ := res["kind"].(string) m.kind = kind metadata, _ := res["metadata"].(map[string]any) name, _ := metadata["name"].(string) m.name = name namespace, _ := metadata["namespace"].(string) m.namespace = namespace return m } func readManifest(path string) ([]resource, error) { var err error f, err := os.Open(path) if err != nil { return nil, err } defer func() { _ = f.Close() }() decoder := yaml.NewYAMLToJSONDecoder(f) resources := []resource{} for { r := make(resource) err = decoder.Decode(&r) if err != nil { break } if len(r) > 0 { resources = append(resources, r) } } if err != nil && err != io.EOF { return nil, err } return resources, nil } func (res resource) getID() string { meta := res.getMeta() ns := meta.namespace if ns == "" { ns = "~X" } nm := meta.name if nm == "" { nm = "~N" } gv := meta.apiVersion if gv == "" { gv = "~G_~V" } k := meta.kind if k == "" { k = "~K" } gvk := strings.Join([]string{gv, k}, "_") return strings.Join([]string{gvk, ns, nm}, "|") } // lifted from kustomize/kyaml/kio/filters/merge3.go type pairs struct { list []*pair } func (ps *pairs) isSameResource(meta1, meta2 meta) bool { if meta1.name != meta2.name { return false } if meta1.namespace != meta2.namespace { return false } if meta1.apiVersion != meta2.apiVersion { return false } if meta1.kind != meta2.kind { return false } return true } func (ps *pairs) add(node resource, source diffSource) error { nodeMeta := node.getMeta() for i := range ps.list { p := ps.list[i] if ps.isSameResource(p.meta, nodeMeta) { return p.add(node, source) } } p := &pair{meta: nodeMeta} if err := p.add(node, source); err != nil { return err } ps.list = append(ps.list, p) return nil } type pair struct { meta meta left resource right resource } func (p *pair) add(node resource, source diffSource) error { switch source { case diffSourceLeft: if p.left != nil { return fmt.Errorf("left source already specified") } p.left = node case diffSourceRight: if p.right != nil { return fmt.Errorf("right source already specified") } p.right = node default: return fmt.Errorf("unknown diff source value: %s", source) } return nil } func main() { err := rootCmd.Execute() if err != nil { fmt.Println(fmt.Errorf("unexpected error: %v", err)) os.Exit(1) } }