Skip to content

feat: show diff when applying supported resources like ServiceAccount - #103

Merged
qboileau merged 10 commits into
conduktor:mainfrom
RabbitSharp:apply-with-diff
Aug 11, 2025
Merged

qboileau merged 10 commits into
conduktor:mainfrom
RabbitSharp:apply-with-diff

Conversation

@RabbitSharp

Copy link
Copy Markdown
Contributor

This PR introduces the ability to display a unified, human-readable diff when applying certain resources—starting with ServiceAccount—to help users understand what changes are being made.

What's New

  • Diff Support for ServiceAccount Resources:

    • Added DiffIsSupported() and PrintDiff() functions to the utils package.
    • Implemented DiffServiceAccount() to compare the current and modified versions using YAML serialization and go-diff.
  • runApply Enhancements:

    • Before applying a resource, if it supports diffing, the current version is fetched via GetFromResource() and the diff is printed.
  • New Struct Conversion Logic:

    • Introduced ConvertToServiceAccount() to safely convert a generic Resource into a typed ServiceAccount struct.

Dependencies
Added: github.com/sergi/go-diff v1.1.0 for generating readable diffs.

How It Works
For supported resources:

  1. Fetch the current version from the API.
  2. Marshal both current and updated versions to YAML.
  3. Use diffmatchpatch to generate a semantic diff.
  4. Print the result before applying the changes.

Notes
Only ServiceAccount is supported for now. Additional resource types can be added by extending supportedTypes and implementing corresponding diff logic.

This feature is particularly useful during dry runs and debugging configuration changes.

@RabbitSharp

Copy link
Copy Markdown
Contributor Author

Example Output:
image

@qboileau

Copy link
Copy Markdown
Member

Thank you for your contribution and feature proposal.

Showing diff of changes on resources is a nice addition to the CLI apply command and we would be happy to merge it.

However, we try to keep the CLI as resource agnostic as possible and handle all of them the same way.
For this you need not cast into a typed structure (here ServiceAccount) but use generic interface{} to compute the resource state diff.

Here is an example snippet that reuse your utils/diff.go file but without specific casting into ServiceAccount :

package utils

import (
	"encoding/json"
	"fmt"
	"github.com/conduktor/ctl/resource"
	"github.com/sergi/go-diff/diffmatchpatch"
	"gopkg.in/yaml.v3"
	"sort"
)

// PrintDiff dispatches the diff operation for supported resource types
// and prints the resulting diff to stdout.
func PrintDiff(currentResource *resource.Resource, modifiedResource *resource.Resource) error {
	txt, err := DiffResources(currentResource, modifiedResource)
	if err != nil {
		return err
	}
	fmt.Printf("%s\n", txt)
	return nil
}

// DiffResources compares two Resources objects and returns a unified diff in git-like format.
func DiffResources(a, b *resource.Resource) (string, error) {
	var aObj, bObj interface{}
	var err error
	// unmarshall the JSON data into a generic interface{}
	err = json.Unmarshal(a.Json, &aObj)
	if err != nil {
		return "", err
	}
	err = json.Unmarshal(b.Json, &bObj)
	if err != nil {
		return "", err
	}

	//// recursively sort the maps and slices in generic interface{}
	sortedA := sortInterface(aObj)
	sortedB := sortInterface(bObj)

	// Marshal both structs back to YAML
	yamlA, err := yaml.Marshal(sortedA)
	if err != nil {
		return "", err
	}
	yamlB, err := yaml.Marshal(sortedB)
	if err != nil {
		return "", err
	}

	// Create a new diff instance
	dmp := diffmatchpatch.New()

	// Generate unified diff
	diffs := dmp.DiffMain(string(yamlA), string(yamlB), false)

	// Format the diff nicely
	diffText := dmp.DiffPrettyText(diffs)
	return diffText, nil
}

func sortInterface(input interface{}) interface{} {
	switch v := input.(type) {
	case map[string]interface{}:
		// Sort map keys
		sortedMap := make(map[string]interface{})
		keys := make([]string, 0, len(v))
		for key := range v {
			keys = append(keys, key)
		}
		sort.Strings(keys)
		for _, key := range keys {
			sortedMap[key] = sortInterface(v[key])
		}
		return sortedMap
	case []interface{}:
		// Sort slices
		for i := range v {
			v[i] = sortInterface(v[i])
		}
		sort.SliceStable(v, func(i, j int) bool {
			return fmt.Sprintf("%v", v[i]) < fmt.Sprintf("%v", v[j])
		})
		return v
	default:
		// Return other types as-is
		return v
	}
}

What this snippet does is :

  1. use the []byte json on each Resrouces you want to compare and unmarshall it into generic interface{} structure
  2. sort slices/map keys into generic interface{} to have a stable output for diff computation
  3. marshall generic sorted interfaces{} into yaml
  4. generate diff of yamls

Another concert is also to only compute and print diff when requested and there is a diff. Because diff computation need to get current state of the resource before apply it could downgrade performance in why applying a lot of resources at once.

For this we need to trigger the diff behind a CLI flag like --print-diff for example.
And better if possible to only print the diff if previous and new resource state are indeed different to not flood ouput when nothing is chaning.

So runApply and initApply functions in cmd/apply.go should look a bit like this :

func runApply(kinds schema.KindCatalog, filePath []string, strict bool) {
	resources := loadResourceFromFileFlag(filePath, strict)
	schema.SortResourcesForApply(kinds, resources, *debug)
	allSuccess := true
	for _, res := range resources {
		var upsertResult string
		var err error
		var currentRes *resource.Resource = nil

		if isGatewayResource(res, kinds) {
			if printDiff {
				var tempRes resource.Resource
				tempRes, err = gatewayApiClient().GetFromResource(&res)
				currentRes = &tempRes
			}
			upsertResult, err = gatewayApiClient().Apply(&res, *dryRun)
		} else {
			if printDiff {
				var tempRes resource.Resource
				tempRes, err = consoleApiClient().GetFromResource(&res)
				currentRes = &tempRes
			}
			upsertResult, err = consoleApiClient().Apply(&res, *dryRun)
		}
		if err != nil {
			fmt.Fprintf(os.Stderr, "Could not apply resource %s/%s: %s\n", res.Kind, res.Name, err)
			allSuccess = false
		} else {
			if printDiff && currentRes != nil {
				err = utils.PrintDiff(currentRes, &res)
			}
			fmt.Printf("%s/%s: %s\n", res.Kind, res.Name, upsertResult)
		}
	}
	if !allSuccess {
		os.Exit(1)
	}
}

func initApply(kinds schema.KindCatalog, strict bool) {
	// applyCmd represents the apply command
	var filePath *[]string
	var applyCmd = &cobra.Command{
		Use:   "apply",
		Short: "Upsert a resource on Conduktor",
		Long:  ``,
		Run: func(cmd *cobra.Command, args []string) {
			runApply(kinds, *filePath, strict)
		},
	}

	rootCmd.AddCommand(applyCmd)

	filePath = applyCmd.
		PersistentFlags().StringArrayP("file", "f", make([]string, 0, 0), "Specify the files to apply")

	dryRun = applyCmd.
		PersistentFlags().Bool("dry-run", false, "Don't really apply change but check on backend the effect if applied")
	printDiff = applyCmd.
		PersistentFlags().Bool("print-diff", false, "Print the diff between the current resource and the one to be applied")

	applyCmd.MarkPersistentFlagRequired("file")
}

And finally, don't forget to cleanup ServiceAccount struct and conversion method on Resource.

@RabbitSharp

Copy link
Copy Markdown
Contributor Author

I have implemented the above suggestions. However, I only made the absolute necessary adjustments for the gateway resources. Since I know little about them and don’t have a way to test them properly, I don’t feel comfortable implementing it, even if it should work exactly like the console ressources.

@qboileau qboileau left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you again for taking the time to implement my suggestion. And thank for the extensive tests on diff and sort functions.

I've done some tests locally on Gateway resources and implemented the same console apply behavior in gateway client and it seems to work as expected so you can implement it.

Apart from this I had some nitpicking questions regarding tests that are not blocker for this PR to be merged.

So when diff is also implemented on gateway resources, this PR will be good to merge for me.

Comment thread client/console_client.go Outdated
Comment thread client/gateway_client.go Outdated
Comment thread utils/diff_test.go Outdated
Comment thread utils/diff_test.go Outdated

@qboileau qboileau left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good for me,
Thank you again for your contribution and efforts.

@qboileau
qboileau merged commit e7449ad into conduktor:main Aug 11, 2025
@strokyl

strokyl commented Sep 16, 2025

Copy link
Copy Markdown
Contributor

Just released in v0.6.3 sorry for the delay

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants