diff --git a/internal/cmd/common.go b/internal/cmd/common.go index 85b5f899..3bc8a4da 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -95,7 +95,7 @@ func applyReportOptionsFlags(cmd *cobra.Command) { cmd.Flags().BoolVar(&reportOptions.detectRenames, "detect-renames", defaults.detectRenames, "enable detection for renames (document level for Kubernetes resources)") // Main output preferences - cmd.Flags().StringVarP(&reportOptions.style, "output", "o", defaults.style, "specify the output style, supported styles: human, brief, github, gitlab, gitea") + cmd.Flags().StringVarP(&reportOptions.style, "output", "o", defaults.style, "specify the output style, supported styles: human, brief, github, gitlab, gitea, yaml") cmd.Flags().BoolVarP(&reportOptions.omitHeader, "omit-header", "b", defaults.omitHeader, "omit the dyff summary header") cmd.Flags().BoolVarP(&reportOptions.exitWithCode, "set-exit-code", "s", defaults.exitWithCode, "set program exit code, with 0 meaning no difference, 1 for differences detected, and 255 for program error") @@ -280,6 +280,11 @@ func writeReport(cmd *cobra.Command, report dyff.Report) error { }, } + case "yaml", "yml": + reportWriter = &dyff.YAMLReport{ + Report: report, + } + case "brief", "short", "summary": reportWriter = &dyff.BriefReport{ Report: report, diff --git a/pkg/dyff/core_identifier.go b/pkg/dyff/core_identifier.go index 3623d5fd..15b63241 100644 --- a/pkg/dyff/core_identifier.go +++ b/pkg/dyff/core_identifier.go @@ -22,6 +22,7 @@ package dyff import ( "fmt" + "regexp" "strings" yamlv3 "gopkg.in/yaml.v3" @@ -136,6 +137,57 @@ func (i *k8sItemIdentifier) Name(node *yamlv3.Node) (string, error) { return strings.Join(elem, "/"), nil } -func (lf *k8sItemIdentifier) String() string { +func (i *k8sItemIdentifier) String() string { return "resource" } + +func K8sMetaFromName(name string) (*K8sMetadata, error) { + parts := strings.Split(name, "/") + + switch len(parts) { + case 3: + // Minimum case. Must be APIVersion/Kind/Name + // where APIVersion has no group + return &K8sMetadata{ + APIVersion: parts[0], + Kind: parts[1], + Metadata: map[string]string{ + "name": parts[2], + }, + }, nil + case 4: + // Could be APIVersion/Kind/Namespace/Name or APIVersion/Kind/Name + // if APIVersion has a group i.e. apps + if regexp.MustCompile(`^v\d+([a-z]+\d+)?$`).MatchString(parts[0]) { + return &K8sMetadata{ + APIVersion: parts[0], + Kind: parts[1], + Metadata: map[string]string{ + "namespace": parts[2], + "name": parts[3], + }, + }, nil + } + return &K8sMetadata{ + APIVersion: parts[0] + "/" + parts[1], + Kind: parts[2], + Metadata: map[string]string{ + "name": parts[3], + }, + }, nil + + case 5: + // Maximum case. Must be APIVersion/Group/Kind/Namespace/Name + // where APIVersion has a group i.e. apps + return &K8sMetadata{ + APIVersion: parts[0] + "/" + parts[1], + Kind: parts[2], + Metadata: map[string]string{ + "namespace": parts[3], + "name": parts[4], + }, + }, nil + } + + return nil, fmt.Errorf("invalid resource name %q", name) +} diff --git a/pkg/dyff/models.go b/pkg/dyff/models.go index d69a597f..32d1ab9b 100644 --- a/pkg/dyff/models.go +++ b/pkg/dyff/models.go @@ -45,6 +45,12 @@ type Detail struct { Kind rune } +type K8sMetadata struct { + APIVersion string + Kind string + Metadata map[string]string +} + // Diff encapsulates everything noteworthy about a difference type Diff struct { Path *ytbx.Path diff --git a/pkg/dyff/output_yaml.go b/pkg/dyff/output_yaml.go new file mode 100644 index 00000000..0a582cd9 --- /dev/null +++ b/pkg/dyff/output_yaml.go @@ -0,0 +1,146 @@ +package dyff + +import ( + "bufio" + "github.com/gonvenience/neat" + "github.com/gonvenience/ytbx" + "io" +) + +type YAMLReport struct { + Report +} + +type YAMLReportDiff struct { + Details map[string]string + Path string +} + +type YAMLReportOutput struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]string `yaml:"metadata"` + Diffs []YAMLReportDiff `yaml:"diffs"` +} + +// TODO: Support non-Kubernetes yaml documents +func (report *YAMLReport) WriteReport(out io.Writer) error { + writer := bufio.NewWriter(out) + defer writer.Flush() + consolidatedDiff, err := report.consolidateDiff() + if err != nil { + return err + } + for file, diffs := range consolidatedDiff { + meta, err := K8sMetaFromName(file) + if err != nil { + return err + } + var d []YAMLReportDiff + for _, diff := range diffs { + d = append(d, YAMLReportDiff{ + Path: diff.Path, + Details: diff.Details, + }) + } + data := YAMLReportOutput{ + APIVersion: meta.APIVersion, + Kind: meta.Kind, + Metadata: meta.Metadata, + Diffs: d, + } + + // Use neat to format the YAML output + yamlData, err := neat.NewOutputProcessor(false, true, nil).ToYAML(data) + if err != nil { + return err + } + + if _, err := writer.WriteString(yamlData); err != nil { + return err + } + } + + _, _ = writer.WriteString("\n") // Ensure a newline at the end of the report + return nil +} + +func (report *YAMLReport) consolidateDiff() (map[string][]YAMLReportDiff, error) { + fileDiffs := make(map[string][]YAMLReportDiff) + + for _, diff := range report.Diffs { + deet := make(map[string]string) + switch len(diff.Details) { + case 1: + switch diff.Details[0].Kind { + case ADDITION: + ytbx.RestructureObject(diff.Details[0].To) + output, err := neat.NewOutputProcessor(false, true, nil).ToYAML(diff.Details[0].To) + if err != nil { + return nil, err + } + deet["to"] = output + deet["from"] = "" + deet["kind"] = "addition" + case REMOVAL: + ytbx.RestructureObject(diff.Details[0].From) + output, err := neat.NewOutputProcessor(false, true, nil).ToYAML(diff.Details[0].From) + if err != nil { + return nil, err + } + deet["to"] = "" + deet["from"] = output + deet["kind"] = "removal" + case MODIFICATION: + ytbx.RestructureObject(diff.Details[0].To) + outputTo, err := neat.NewOutputProcessor(false, true, nil).ToYAML(diff.Details[0].To) + ytbx.RestructureObject(diff.Details[0].From) + outputFrom, err := neat.NewOutputProcessor(false, true, nil).ToYAML(diff.Details[0].From) + if err != nil { + return nil, err + } + deet["to"] = outputTo + deet["from"] = outputFrom + deet["kind"] = "modification" + case ORDERCHANGE: + ytbx.RestructureObject(diff.Details[0].To) + outputTo, err := neat.NewOutputProcessor(false, true, nil).ToYAML(diff.Details[0].To) + ytbx.RestructureObject(diff.Details[0].From) + outputFrom, err := neat.NewOutputProcessor(false, true, nil).ToYAML(diff.Details[0].From) + if err != nil { + return nil, err + } + deet["to"] = outputTo + deet["from"] = outputFrom + deet["kind"] = "orderchange" + } + case 2: + for _, detail := range diff.Details { + switch detail.Kind { + case ADDITION: + ytbx.RestructureObject(detail.To) + output, err := neat.NewOutputProcessor(false, true, nil).ToYAML(detail.To) + if err != nil { + return nil, err + } + deet["to"] = output + case REMOVAL: + ytbx.RestructureObject(detail.From) + output, err := neat.NewOutputProcessor(false, true, nil).ToYAML(detail.From) + if err != nil { + return nil, err + } + deet["from"] = output + } + } + deet["kind"] = "modification" + } + + fileDiffs[diff.Path.RootDescription()] = append(fileDiffs[diff.Path.RootDescription()], YAMLReportDiff{ + Path: diff.Path.String(), + Details: deet, + }) + } + + return fileDiffs, nil +}