helmfile/test/diff-yamls/diff-yamls.go

277 lines
5.6 KiB
Go

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)
}
}