Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion client/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ require (
github.com/mattn/go-isatty v0.0.21
github.com/neticdk/go-stdlib v1.0.1
github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25
github.com/sergi/go-diff v1.4.0
github.com/siderolabs/gen v0.8.6
github.com/siderolabs/go-api-signature v0.3.12
github.com/siderolabs/go-kubeconfig v0.1.2
Expand Down
5 changes: 0 additions & 5 deletions client/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,8 @@ github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
Expand Down Expand Up @@ -360,14 +357,12 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
272 changes: 272 additions & 0 deletions client/pkg/execdiff/execdiff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

// Package execdiff provides diff rendering for dry-run operations,
// supporting both built-in colorized output and external diff tools
// via the OMNI_EXTERNAL_DIFF environment variable.
package execdiff

import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/fatih/color"

"github.com/siderolabs/omni/client/pkg/diff"
)

// EnvExternalDiff is the environment variable that specifies an external diff program.
//
// When set, the diff tool is invoked with two directory paths containing the
// old and new resource YAML files. The value may include arguments, e.g.
// "colordiff -N -u".
//
// Exit codes: 0 = no differences, 1 = differences found, >1 = error.
const EnvExternalDiff = "OMNI_EXTERNAL_DIFF"

// ErrDifferencesFound is a sentinel error returned by callers of Differ.Flush()
// when they want to signal that differences were found. Commands should wrap
// their result with this sentinel so that the top-level CLI can translate it
// into the documented exit status (1 = differences found).
var ErrDifferencesFound = errors.New("differences found")

type entry struct {
label string
filename string
oldYAML []byte
newYAML []byte
}

// Differ accumulates resource diffs and renders them either using a built-in
// colorized unified diff or by invoking an external diff program.
type Differ struct {
w io.Writer
extCmd string
entries []entry
hasDiff bool
}

// New creates a Differ that writes to w. If OMNI_EXTERNAL_DIFF is set,
// diffs are queued and rendered by the external tool on Flush.
func New(w io.Writer) *Differ {
return &Differ{
w: w,
extCmd: os.Getenv(EnvExternalDiff),
}
}

// IsExternal returns true if an external diff tool is configured.
func (d *Differ) IsExternal() bool {
return d.extCmd != ""
}

// SanitizeFilename returns a safe filename derived from the given parts.
//
// Path separators and traversal sequences are replaced with underscores so
// that joining the result with a temp directory base path cannot escape the
// directory. Empty / dot-only parts are replaced with an underscore.
func SanitizeFilename(parts ...string) string {
out := make([]string, 0, len(parts))

for _, p := range parts {
out = append(out, sanitizeFilenamePart(p))
}

return strings.Join(out, "-")
}

func sanitizeFilenamePart(part string) string {
sanitized := strings.NewReplacer(
"/", "_",
"\\", "_",
"..", "__",
":", "_",
"\x00", "_",
).Replace(part)

switch sanitized {
case "", ".", "..":
return "_"
default:
return sanitized
}
}

// AddDiff records a diff between two versions of a resource.
//
// For creates, oldYAML should be nil. For deletes, newYAML should be nil.
// label is a human-readable description (e.g. "MachineSet(cluster1)").
// filename is used as the YAML file name in temp directories for external mode;
// callers must provide a sanitized filename (see SanitizeFilename) - filenames
// that contain path separators or traversal sequences are rejected.
//
// In built-in mode, the diff is rendered immediately to the writer.
// In external mode, the entry is queued for Flush.
func (d *Differ) AddDiff(label, filename string, oldYAML, newYAML []byte) error {
if d.IsExternal() {
if err := validateFilename(filename); err != nil {
return err
}

d.entries = append(d.entries, entry{
label: label,
filename: filename,
oldYAML: oldYAML,
newYAML: newYAML,
})

return nil
}

return d.renderBuiltin(label, oldYAML, newYAML)
}

// validateFilename ensures the filename is safe to join with a temp directory
// base path: no path separators, no traversal components, not absolute, not empty.
func validateFilename(name string) error {
if name == "" {
return errors.New("empty filename")
}

if filepath.IsAbs(name) {
return fmt.Errorf("absolute path not allowed: %q", name)
}

if name != filepath.Base(name) {
return fmt.Errorf("filename must not contain path separators: %q", name)
}

// filepath.Base("..") returns "..", so this also blocks parent-traversal.
if name == "." || name == ".." {
return fmt.Errorf("invalid filename: %q", name)
}

return nil
}

// Flush invokes the external diff tool with temp directories containing
// the queued entries. Returns true if differences were found.
//
// In built-in mode this is a no-op and returns hasDiff accumulated from AddDiff calls.
func (d *Differ) Flush() (bool, error) {
if !d.IsExternal() {
return d.hasDiff, nil
}

if len(d.entries) == 0 {
return false, nil
}
Comment thread
Unix4ever marked this conversation as resolved.

liveDir, err := os.MkdirTemp("", "omni-diff-LIVE-")
if err != nil {
return false, fmt.Errorf("failed to create temp dir: %w", err)
}

defer os.RemoveAll(liveDir) //nolint:errcheck

mergedDir, err := os.MkdirTemp("", "omni-diff-MERGED-")
if err != nil {
return false, fmt.Errorf("failed to create temp dir: %w", err)
}

defer os.RemoveAll(mergedDir) //nolint:errcheck

for _, e := range d.entries {
// Filenames were validated in AddDiff, but defend in depth: re-validate
// here to avoid any path escape if the slice was mutated.
if err := validateFilename(e.filename); err != nil {
return false, err
}

if e.oldYAML != nil {
if err := os.WriteFile(filepath.Join(liveDir, e.filename), e.oldYAML, 0o600); err != nil {
return false, fmt.Errorf("failed to write old YAML for %s: %w", e.label, err)
}
Comment thread
Unix4ever marked this conversation as resolved.
}

if e.newYAML != nil {
if err := os.WriteFile(filepath.Join(mergedDir, e.filename), e.newYAML, 0o600); err != nil {
return false, fmt.Errorf("failed to write new YAML for %s: %w", e.label, err)
}
Comment thread
p2pdkivenko marked this conversation as resolved.
}
}

return d.runExternal(liveDir, mergedDir)
}

func (d *Differ) renderBuiltin(label string, oldYAML, newYAML []byte) error {
diffStr, err := diff.Compute(oldYAML, newYAML)
if err != nil {
return err
}

// Strip the library's generic header; we print our own with the label.
diffStr, _ = strings.CutPrefix(diffStr, "--- a\n+++ b\n")

if diffStr == "" {
return nil
}

d.hasDiff = true

bold := color.New(color.Bold)
bold.Fprintf(d.w, "--- %s\n", label) //nolint:errcheck
bold.Fprintf(d.w, "+++ %s\n", label) //nolint:errcheck

cyan := color.New(color.FgCyan)
red := color.New(color.FgRed)
green := color.New(color.FgGreen)

for line := range strings.SplitSeq(diffStr, "\n") {
switch {
case strings.HasPrefix(line, "@@"):
cyan.Fprintln(d.w, line) //nolint:errcheck
case strings.HasPrefix(line, "-"):
red.Fprintln(d.w, line) //nolint:errcheck
case strings.HasPrefix(line, "+"):
green.Fprintln(d.w, line) //nolint:errcheck
case line == "":
// skip trailing empty line
default:
fmt.Fprintln(d.w, line) //nolint:errcheck
}
}

return nil
}

func (d *Differ) runExternal(liveDir, mergedDir string) (bool, error) {
parts := strings.Fields(d.extCmd)
if len(parts) == 0 {
return false, errors.New("OMNI_EXTERNAL_DIFF is set but empty after parsing")
}

name := parts[0]
args := append(parts[1:], liveDir, mergedDir)

cmd := exec.Command(name, args...)
cmd.Stdout = d.w
cmd.Stderr = d.w

err := cmd.Run()
if err == nil {
return false, nil
}

var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr.ExitCode() == 1 {
return true, nil
}

return false, fmt.Errorf("external diff exited with code %d", exitErr.ExitCode())
}

return false, fmt.Errorf("failed to run external diff %q: %w", name, err)
}
Loading