Files
Jose Diaz-Gonzalez 496ede44b7 feat: add scheduler-k3s:preview subcommand
Adds a `scheduler-k3s:preview <app>` command that renders a unified diff
between the manifests currently stored in the live Helm release for an app
and the manifests that the next deploy would roll out. The diff is produced
entirely via the Helm Go SDK that scheduler-k3s already uses, with no
external `helm` binary or `helm-diff` plugin install required. The
underlying diff and parse logic is vendored from `databus23/helm-diff`
(Apache 2.0) into a new internal package, trimmed of three-way-merge,
release-ownership tracking, and the non-default output formatters.

The chart-construction half of `TriggerSchedulerDeploy` has been extracted
into a reusable `BuildAppChart` helper so deploy and preview share the
same chart materialization without duplicating template logic. Secret
values are redacted by default in preview output; `--show-secrets` and
`--show-secrets-decoded` flags match the upstream `helm diff` UX. Only the
main app helm release is compared in this iteration; auxiliary releases
for config vars, image-pull secrets, and TLS certificates are out of scope
and documented as such.
2026-06-02 20:28:31 -04:00

521 lines
15 KiB
Go

// Derived from github.com/databus23/helm-diff (Apache License 2.0).
// See LICENSE and NOTICE.md in this directory.
package helmdiff
import (
"bytes"
"fmt"
"io"
"math"
"regexp"
"sort"
"strings"
"github.com/aryann/difflib"
"github.com/mgutz/ansi"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime/serializer/json"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/kubernetes/scheme"
)
// Options controls the behavior of Manifests.
type Options struct {
OutputFormat string
OutputContext int
StripTrailingCR bool
ShowSecrets bool
ShowSecretsDecoded bool
SuppressedKinds []string
FindRenames float32
SuppressedOutputLineRegex []string
}
const kindSecret = "Secret"
// Manifests compares two indexed manifest maps and writes a unified diff to to.
// It returns true if any changes were detected.
func Manifests(oldIndex, newIndex map[string]*MappingResult, options *Options, to io.Writer) bool {
seenAnyChanges, report, err := generateReport(oldIndex, newIndex, options)
if err != nil {
panic(err)
}
report.print(to)
report.clean()
return seenAnyChanges
}
// ManifestReport is like Manifests but returns the constructed Report instead
// of writing it. Useful for tests.
func ManifestReport(oldIndex, newIndex map[string]*MappingResult, options *Options) (*Report, error) {
_, report, err := generateReport(oldIndex, newIndex, options)
return report, err
}
func generateReport(oldIndex, newIndex map[string]*MappingResult, options *Options) (bool, *Report, error) {
report := Report{findRenames: options.FindRenames}
report.setupReportFormat(options.OutputFormat)
var possiblyRemoved []string
for _, key := range sortedKeys(oldIndex) {
oldContent := oldIndex[key]
if newContent, ok := newIndex[key]; ok {
doDiff(&report, key, oldContent, newContent, options)
} else {
possiblyRemoved = append(possiblyRemoved, key)
}
}
var possiblyAdded []string
for _, key := range sortedKeys(newIndex) {
if _, ok := oldIndex[key]; !ok {
possiblyAdded = append(possiblyAdded, key)
}
}
removed, added := contentSearch(&report, possiblyRemoved, oldIndex, possiblyAdded, newIndex, options)
for _, key := range removed {
oldContent := oldIndex[key]
if oldContent.ResourcePolicy != "keep" {
doDiff(&report, key, oldContent, nil, options)
}
}
for _, key := range added {
newContent := newIndex[key]
doDiff(&report, key, nil, newContent, options)
}
seenAnyChanges := len(report.Entries) > 0
report, err := doSuppress(report, options.SuppressedOutputLineRegex)
return seenAnyChanges, &report, err
}
func doSuppress(report Report, suppressedOutputLineRegex []string) (Report, error) {
if len(report.Entries) == 0 || len(suppressedOutputLineRegex) == 0 {
return report, nil
}
filteredReport := Report{
findRenames: report.findRenames,
}
filteredReport.format = report.format
filteredReport.Entries = []ReportEntry{}
var suppressOutputRegexes []*regexp.Regexp
for _, suppressOutputRegex := range suppressedOutputLineRegex {
regex, err := regexp.Compile(suppressOutputRegex)
if err != nil {
return Report{}, err
}
suppressOutputRegexes = append(suppressOutputRegexes, regex)
}
for _, entry := range report.Entries {
var diffs []difflib.DiffRecord
DIFFS:
for _, diff := range entry.Diffs {
for _, suppressOutputRegex := range suppressOutputRegexes {
if suppressOutputRegex.MatchString(diff.Payload) {
continue DIFFS
}
}
diffs = append(diffs, diff)
}
containsDiff := false
for _, diff := range diffs {
if diff.Delta.String() != " " {
containsDiff = true
break
}
}
diffRecords := []difflib.DiffRecord{}
switch {
case containsDiff:
diffRecords = diffs
case entry.ChangeType == "MODIFY":
entry.ChangeType = "MODIFY_SUPPRESSED"
}
filteredReport.addEntry(entry.Key, entry.SuppressedKinds, entry.Kind, entry.Context, diffRecords, entry.ChangeType)
}
return filteredReport, nil
}
func actualChanges(diff []difflib.DiffRecord) int {
changes := 0
for _, record := range diff {
if record.Delta != difflib.Common {
changes++
}
}
return changes
}
const (
renameDetectionMinLengthRatio float32 = 0.1
renameDetectionMaxLengthRatio float32 = 10.0
)
func contentSearch(report *Report, possiblyRemoved []string, oldIndex map[string]*MappingResult, possiblyAdded []string, newIndex map[string]*MappingResult, options *Options) ([]string, []string) {
if options.FindRenames <= 0 {
return possiblyRemoved, possiblyAdded
}
var removed []string
for _, removedKey := range possiblyRemoved {
oldContent := oldIndex[removedKey]
var smallestKey string
var smallestFraction float32 = math.MaxFloat32
for _, addedKey := range possiblyAdded {
newContent := newIndex[addedKey]
if oldContent.Kind != newContent.Kind {
continue
}
oldLen := len(oldContent.Content)
newLen := len(newContent.Content)
if oldLen == 0 || newLen == 0 {
continue
}
if oldContent.Kind != kindSecret {
ratio := float32(oldLen) / float32(newLen)
if ratio < renameDetectionMinLengthRatio || ratio > renameDetectionMaxLengthRatio {
continue
}
}
switch {
case options.ShowSecretsDecoded:
decodeSecrets(oldContent, newContent)
case !options.ShowSecrets:
redactSecrets(oldContent, newContent)
}
diff := diffMappingResults(oldContent, newContent, options.StripTrailingCR)
delta := actualChanges(diff)
if delta == 0 || len(diff) == 0 {
continue
}
fraction := float32(delta) / float32(len(diff))
if fraction > 0 && fraction < smallestFraction {
smallestKey = addedKey
smallestFraction = fraction
}
}
if smallestFraction < options.FindRenames {
index := sort.SearchStrings(possiblyAdded, smallestKey)
possiblyAdded = append(possiblyAdded[:index], possiblyAdded[index+1:]...)
newContent := newIndex[smallestKey]
doDiff(report, removedKey, oldContent, newContent, options)
} else {
removed = append(removed, removedKey)
}
}
return removed, possiblyAdded
}
func doDiff(report *Report, key string, oldContent *MappingResult, newContent *MappingResult, options *Options) {
if oldContent != nil && newContent != nil && oldContent.Content == newContent.Content {
return
}
switch {
case options.ShowSecretsDecoded:
decodeSecrets(oldContent, newContent)
case !options.ShowSecrets:
redactSecrets(oldContent, newContent)
}
var changeType string
var subjectKind string
var diffs []difflib.DiffRecord
switch {
case oldContent == nil:
changeType = "ADD"
if newContent != nil {
subjectKind = newContent.Kind
emptyMapping := &MappingResult{}
diffs = diffMappingResults(emptyMapping, newContent, options.StripTrailingCR)
}
case newContent == nil:
changeType = "REMOVE"
subjectKind = oldContent.Kind
emptyMapping := &MappingResult{}
diffs = diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR)
default:
changeType = "MODIFY"
subjectKind = oldContent.Kind
diffs = diffMappingResults(oldContent, newContent, options.StripTrailingCR)
if actualChanges(diffs) == 0 {
return
}
}
report.addEntry(key, options.SuppressedKinds, subjectKind, options.OutputContext, diffs, changeType)
}
func preHandleSecrets(old, new *MappingResult) (v1.Secret, v1.Secret, error, error) {
var oldSecretDecodeErr, newSecretDecodeErr error
var oldSecret, newSecret v1.Secret
if old != nil {
oldSecretDecodeErr = yaml.NewYAMLToJSONDecoder(bytes.NewBufferString(old.Content)).Decode(&oldSecret)
if oldSecretDecodeErr != nil {
old.Content = fmt.Sprintf("Error parsing old secret: %s", oldSecretDecodeErr)
} else {
if len(oldSecret.StringData) > 0 && oldSecret.Data == nil {
oldSecret.Data = make(map[string][]byte, len(oldSecret.StringData))
}
for k, v := range oldSecret.StringData {
oldSecret.Data[k] = []byte(v)
}
}
}
if new != nil {
newSecretDecodeErr = yaml.NewYAMLToJSONDecoder(bytes.NewBufferString(new.Content)).Decode(&newSecret)
if newSecretDecodeErr != nil {
new.Content = fmt.Sprintf("Error parsing new secret: %s", newSecretDecodeErr)
} else {
if len(newSecret.StringData) > 0 && newSecret.Data == nil {
newSecret.Data = make(map[string][]byte, len(newSecret.StringData))
}
for k, v := range newSecret.StringData {
newSecret.Data[k] = []byte(v)
}
}
}
return oldSecret, newSecret, oldSecretDecodeErr, newSecretDecodeErr
}
// redactSecrets replaces Secret data values with placeholders so the actual
// secret bytes never appear in diff output.
func redactSecrets(old, new *MappingResult) {
if (old != nil && old.Kind != kindSecret) || (new != nil && new.Kind != kindSecret) {
return
}
serializer := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme)
oldSecret, newSecret, oldSecretDecodeErr, newSecretDecodeErr := preHandleSecrets(old, new)
if old != nil && oldSecretDecodeErr == nil {
oldSecret.StringData = make(map[string]string, len(oldSecret.Data))
for k, v := range oldSecret.Data {
if new != nil && bytes.Equal(v, newSecret.Data[k]) {
oldSecret.StringData[k] = fmt.Sprintf("REDACTED # (%d bytes)", len(v))
} else {
oldSecret.StringData[k] = fmt.Sprintf("-------- # (%d bytes)", len(v))
}
}
}
if new != nil && newSecretDecodeErr == nil {
newSecret.StringData = make(map[string]string, len(newSecret.Data))
for k, v := range newSecret.Data {
if old != nil && bytes.Equal(v, oldSecret.Data[k]) {
newSecret.StringData[k] = fmt.Sprintf("REDACTED # (%d bytes)", len(v))
} else {
newSecret.StringData[k] = fmt.Sprintf("++++++++ # (%d bytes)", len(v))
}
}
}
if old != nil && oldSecretDecodeErr == nil {
oldSecretBuf := bytes.NewBuffer(nil)
oldSecret.Data = nil
if err := serializer.Encode(&oldSecret, oldSecretBuf); err != nil {
new.Content = fmt.Sprintf("Error encoding new secret: %s", err)
}
old.Content = getComment(old.Content) + strings.Replace(strings.Replace(oldSecretBuf.String(), "stringData", "data", 1), " creationTimestamp: null\n", "", 1)
oldSecretBuf.Reset()
}
if new != nil && newSecretDecodeErr == nil {
newSecretBuf := bytes.NewBuffer(nil)
newSecret.Data = nil
if err := serializer.Encode(&newSecret, newSecretBuf); err != nil {
new.Content = fmt.Sprintf("Error encoding new secret: %s", err)
}
new.Content = getComment(new.Content) + strings.Replace(strings.Replace(newSecretBuf.String(), "stringData", "data", 1), " creationTimestamp: null\n", "", 1)
newSecretBuf.Reset()
}
}
// decodeSecrets renders Secret data values base64-decoded in diff output.
func decodeSecrets(old, new *MappingResult) {
if (old != nil && old.Kind != kindSecret) || (new != nil && new.Kind != kindSecret) {
return
}
serializer := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme)
oldSecret, newSecret, oldSecretDecodeErr, newSecretDecodeErr := preHandleSecrets(old, new)
if old != nil && oldSecretDecodeErr == nil {
oldSecret.StringData = make(map[string]string, len(oldSecret.Data))
for k, v := range oldSecret.Data {
oldSecret.StringData[k] = string(v)
}
}
if new != nil && newSecretDecodeErr == nil {
newSecret.StringData = make(map[string]string, len(newSecret.Data))
for k, v := range newSecret.Data {
newSecret.StringData[k] = string(v)
}
}
if old != nil && oldSecretDecodeErr == nil {
oldSecretBuf := bytes.NewBuffer(nil)
oldSecret.Data = nil
if err := serializer.Encode(&oldSecret, oldSecretBuf); err != nil {
new.Content = fmt.Sprintf("Error encoding new secret: %s", err)
}
old.Content = getComment(old.Content) + strings.Replace(oldSecretBuf.String(), " creationTimestamp: null\n", "", 1)
oldSecretBuf.Reset()
}
if new != nil && newSecretDecodeErr == nil {
newSecretBuf := bytes.NewBuffer(nil)
newSecret.Data = nil
if err := serializer.Encode(&newSecret, newSecretBuf); err != nil {
new.Content = fmt.Sprintf("Error encoding new secret: %s", err)
}
new.Content = getComment(new.Content) + strings.Replace(newSecretBuf.String(), " creationTimestamp: null\n", "", 1)
newSecretBuf.Reset()
}
}
// getComment returns the first line of a string if it is a comment. This
// preserves the leading "# Source: ..." lines from Helm-rendered manifests.
func getComment(s string) string {
i := strings.Index(s, "\n")
if i < 0 || !strings.HasPrefix(s, "#") {
return ""
}
return s[:i+1]
}
func diffMappingResults(oldContent *MappingResult, newContent *MappingResult, stripTrailingCR bool) []difflib.DiffRecord {
return diffStrings(oldContent.Content, newContent.Content, stripTrailingCR)
}
func diffStrings(before, after string, stripTrailingCR bool) []difflib.DiffRecord {
return difflib.Diff(split(before, stripTrailingCR), split(after, stripTrailingCR))
}
func split(value string, stripTrailingCR bool) []string {
const sep = "\n"
split := strings.Split(value, sep)
if !stripTrailingCR {
return split
}
var stripped []string
for _, s := range split {
stripped = append(stripped, strings.TrimSuffix(s, "\r"))
}
return stripped
}
func printDiffRecords(suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, to io.Writer) {
for _, ckind := range suppressedKinds {
if ckind == kind {
str := fmt.Sprintf("+ Changes suppressed on sensitive content of type %s\n", kind)
_, _ = fmt.Fprint(to, ansi.Color(str, "yellow"))
return
}
}
if context >= 0 {
distances := calculateDistances(diffs)
omitting := false
for i, diff := range diffs {
if distances[i] > context {
if !omitting {
_, _ = fmt.Fprintln(to, "...")
omitting = true
}
} else {
omitting = false
printDiffRecord(diff, to)
}
}
} else {
for _, diff := range diffs {
printDiffRecord(diff, to)
}
}
}
func printDiffRecord(diff difflib.DiffRecord, to io.Writer) {
text := diff.Payload
switch diff.Delta {
case difflib.RightOnly:
_, _ = fmt.Fprintf(to, "%s\n", ansi.Color("+ "+text, "green"))
case difflib.LeftOnly:
_, _ = fmt.Fprintf(to, "%s\n", ansi.Color("- "+text, "red"))
case difflib.Common:
if text == "" {
_, _ = fmt.Fprintln(to)
} else {
_, _ = fmt.Fprintf(to, "%s\n", " "+text)
}
}
}
// calculateDistances returns the distance of every diff line to the closest change.
func calculateDistances(diffs []difflib.DiffRecord) map[int]int {
distances := map[int]int{}
change := -1
for i, diff := range diffs {
if diff.Delta != difflib.Common {
change = i
}
distance := math.MaxInt32
if change != -1 {
distance = i - change
}
distances[i] = distance
}
change = -1
for i := len(diffs) - 1; i >= 0; i-- {
diff := diffs[i]
if diff.Delta != difflib.Common {
change = i
}
if change != -1 {
distance := change - i
if distance < distances[i] {
distances[i] = distance
}
}
}
return distances
}
func sortedKeys(manifests map[string]*MappingResult) []string {
var keys []string
for key := range manifests {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}