277 lines
5.6 KiB
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)
|
|
}
|
|
}
|