Skip to content

Commit 5033f6c

Browse files
authored
feat(diff): add list-modified-envs flag to tk diff command (#1623)
* Add functionality * Use list modified envs * Use the new flag * Lint * Remove placeholder comments * Add tests and sort outputs * Changes according to reviewer's comments
1 parent 88954e5 commit 5033f6c

File tree

6 files changed

+200
-4
lines changed

6 files changed

+200
-4
lines changed

cmd/tk/workflow.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ func diffCmd(ctx context.Context) *cli.Command {
232232
cmd.Flags().BoolVarP(&opts.Summarize, "summarize", "s", false, "print summary of the differences, not the actual contents")
233233
cmd.Flags().BoolVarP(&opts.WithPrune, "with-prune", "p", false, "include objects deleted from the configuration in the differences")
234234
cmd.Flags().BoolVarP(&opts.ExitZero, "exit-zero", "z", false, "Exit with 0 even when differences are found.")
235+
cmd.Flags().BoolVar(&opts.ListModifiedEnvs, "list-modified-envs", false, "List environments with changes")
235236

236237
vars := workflowFlags(cmd.Flags())
237238
getJsonnetOpts := jsonnetFlags(cmd.Flags())
@@ -257,13 +258,27 @@ func diffCmd(ctx context.Context) *cli.Command {
257258
}
258259

259260
if changes == nil {
260-
fmt.Fprintln(os.Stderr, "No differences.")
261+
if opts.ListModifiedEnvs {
262+
fmt.Fprintln(os.Stderr, "No environments with changes.")
263+
} else {
264+
fmt.Fprintln(os.Stderr, "No differences.")
265+
}
261266
os.Exit(ExitStatusClean)
262267
}
263268

264-
r := term.Colordiff(*changes)
265-
if err := fPageln(r); err != nil {
266-
return err
269+
// For special modes, print output directly without color processing
270+
if opts.ListModifiedEnvs {
271+
fmt.Print(*changes)
272+
} else {
273+
r := term.Colordiff(*changes)
274+
if err := fPageln(r); err != nil {
275+
return err
276+
}
277+
}
278+
279+
// For --list-modified-envs, always exit with success code
280+
if opts.ListModifiedEnvs {
281+
os.Exit(ExitStatusClean)
267282
}
268283

269284
exitStatusDiff := ExitStatusDiff

pkg/kubernetes/client/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ type Client interface {
1919
// result in `diff(1)` format
2020
DiffServerSide(data manifest.List) (*string, error)
2121

22+
// DiffExitCode performs kubectl diff and returns true if there are changes, false if no changes
23+
DiffExitCode(data manifest.List) (bool, error)
24+
2225
// Delete the specified object(s) from the cluster
2326
Delete(namespace, apiVersion, kind, name string, opts DeleteOpts) error
2427

pkg/kubernetes/client/diff.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ func (k Kubectl) DiffClientSide(data manifest.List) (*string, error) {
3737
return k.diff(data, false)
3838
}
3939

40+
// DiffExitCode performs a kubectl diff and returns true if there are changes (exit code 1), false if no changes (exit code 0)
41+
func (k Kubectl) DiffExitCode(data manifest.List) (bool, error) {
42+
fw := FilterWriter{filters: []*regexp.Regexp{regexp.MustCompile(`exit status \d`)}}
43+
44+
args := []string{"-f", "-"}
45+
cmd := k.ctl("diff", args...)
46+
47+
cmd.Stdout = &bytes.Buffer{}
48+
cmd.Stderr = &fw
49+
cmd.Stdin = strings.NewReader(data.String())
50+
51+
err := cmd.Run()
52+
exitErr, ok := err.(exitError)
53+
if !ok && err != nil {
54+
return false, err
55+
}
56+
57+
if !ok {
58+
// exit code 0 - no changes
59+
return false, nil
60+
}
61+
62+
switch exitErr.ExitCode() {
63+
case 0:
64+
return false, nil
65+
case 1:
66+
return true, nil
67+
default:
68+
return false, err
69+
}
70+
}
71+
4072
func (k Kubectl) diff(data manifest.List, serverSide bool) (*string, error) {
4173
fw := FilterWriter{filters: []*regexp.Regexp{regexp.MustCompile(`exit status \d`)}}
4274

pkg/kubernetes/client/diff_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,47 @@ func (e *dummyExitError) Error() string {
6868
func (e *dummyExitError) ExitCode() int {
6969
return e.exitCode
7070
}
71+
72+
func TestDiffExitCodeMapping(t *testing.T) {
73+
cases := []struct {
74+
name string
75+
err error
76+
expect bool
77+
expectErr bool
78+
}{
79+
{name: "nilErrorNoChanges", err: nil, expect: false, expectErr: false},
80+
{name: "exit0NoChanges", err: &dummyExitError{exitCode: 0}, expect: false, expectErr: false},
81+
{name: "exit1HasChanges", err: &dummyExitError{exitCode: 1}, expect: true, expectErr: false},
82+
}
83+
84+
for _, tc := range cases {
85+
t.Run(tc.name, func(t *testing.T) {
86+
var got bool
87+
var err error
88+
89+
if exitErr, ok := tc.err.(exitError); !ok {
90+
if tc.err != nil {
91+
err = tc.err
92+
} else {
93+
got = false
94+
}
95+
} else {
96+
switch exitErr.ExitCode() {
97+
case 0:
98+
got = false
99+
case 1:
100+
got = true
101+
default:
102+
err = tc.err
103+
}
104+
}
105+
106+
if tc.expectErr {
107+
require.Error(t, err)
108+
return
109+
}
110+
require.NoError(t, err)
111+
require.Equal(t, tc.expect, got)
112+
})
113+
}
114+
}

pkg/kubernetes/diff.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ Please upgrade kubectl to at least version 1.18.1`)
100100
return d, nil
101101
}
102102

103+
// HasChanges performs a lightweight check to determine if there are any changes
104+
// between the desired state and cluster using kubectl diff --exit-code (no output)
105+
func (k *Kubernetes) HasChanges(state manifest.List) (bool, error) {
106+
return k.ctl.DiffExitCode(state)
107+
}
108+
103109
type separateOpts struct {
104110
namespaces map[string]bool
105111
resources client.Resources

pkg/tanka/workflow.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"sort"
8+
"strings"
9+
"sync"
710

811
"github.com/fatih/color"
912
"github.com/rs/zerolog/log"
13+
"golang.org/x/sync/errgroup"
1014

1115
"github.com/grafana/tanka/pkg/kubernetes"
1216
"github.com/grafana/tanka/pkg/kubernetes/client"
1317
"github.com/grafana/tanka/pkg/kubernetes/manifest"
18+
"github.com/grafana/tanka/pkg/spec/v1alpha1"
1419
"github.com/grafana/tanka/pkg/term"
1520
)
1621

@@ -170,6 +175,8 @@ type DiffOpts struct {
170175
WithPrune bool
171176
// Exit with 0 even when differences are found
172177
ExitZero bool
178+
// List all available environments and exit
179+
ListModifiedEnvs bool
173180
}
174181

175182
// Diff parses the environment at the given directory (a `baseDir`) and returns
@@ -178,6 +185,10 @@ type DiffOpts struct {
178185
// The cluster information is retrieved from the environments `spec.json`.
179186
// NOTE: This function requires on `diff(1)` and `kubectl(1)`
180187
func Diff(ctx context.Context, baseDir string, opts DiffOpts) (*string, error) {
188+
if opts.ListModifiedEnvs {
189+
return ListChangedEnvironments(ctx, baseDir, opts)
190+
}
191+
181192
l, err := Load(ctx, baseDir, opts.Opts)
182193
if err != nil {
183194
return nil, err
@@ -195,6 +206,91 @@ func Diff(ctx context.Context, baseDir string, opts DiffOpts) (*string, error) {
195206
})
196207
}
197208

209+
// ListChangedEnvironments performs a high-level check using kubectl dry-run to identify environments with changes
210+
func ListChangedEnvironments(ctx context.Context, baseDir string, opts DiffOpts) (*string, error) {
211+
// Find all environments in the directory
212+
envMetas, err := FindEnvs(ctx, baseDir, FindOpts{
213+
JsonnetOpts: opts.JsonnetOpts,
214+
JsonnetImplementation: opts.JsonnetImplementation,
215+
Parallelism: 8, // magic number for now
216+
})
217+
if err != nil {
218+
return nil, err
219+
}
220+
221+
changed := CheckEnvironmentsForChanges(ctx, envMetas, opts)
222+
if len(changed) == 0 {
223+
return nil, nil
224+
}
225+
226+
sort.Strings(changed)
227+
228+
result := strings.Join(changed, "\n")
229+
return &result, nil
230+
}
231+
232+
// CheckEnvironmentsForChanges performs a high-level parallel check using kubectl diff --exit-code
233+
func CheckEnvironmentsForChanges(ctx context.Context, envs []*v1alpha1.Environment, opts DiffOpts) []string {
234+
var mu sync.Mutex
235+
var changed []string
236+
237+
g, ctx := errgroup.WithContext(ctx)
238+
g.SetLimit(4)
239+
240+
for _, env := range envs {
241+
envLoop := env
242+
g.Go(func() error {
243+
if hasChanges, envName := checkSingleEnvironmentChanges(ctx, envLoop, opts); hasChanges {
244+
mu.Lock()
245+
changed = append(changed, envName)
246+
mu.Unlock()
247+
}
248+
return nil
249+
})
250+
}
251+
252+
if err := g.Wait(); err != nil {
253+
log.Warn().Err(err).Msg("Failed to check environments for changes")
254+
}
255+
return changed
256+
}
257+
258+
// checkSingleEnvironmentChanges uses kubectl diff to quickly check for changes
259+
func checkSingleEnvironmentChanges(ctx context.Context, env *v1alpha1.Environment, opts DiffOpts) (bool, string) {
260+
envName := env.Spec.Namespace
261+
if env.Metadata.Name != "" {
262+
envName = env.Metadata.Name
263+
}
264+
265+
// Load only this single environment to get its resources
266+
tempOpts := opts.Opts
267+
tempOpts.Name = env.Metadata.Name
268+
269+
// Use the environment's path for loading
270+
envPath := env.Metadata.Namespace
271+
l, err := Load(ctx, envPath, tempOpts)
272+
if err != nil {
273+
log.Warn().Err(err).Str("env", envName).Msg("Failed to load environment, assuming no changes")
274+
return false, envName
275+
}
276+
277+
kube, err := l.Connect()
278+
if err != nil {
279+
log.Warn().Err(err).Str("env", envName).Msg("Failed to connect, assuming no changes")
280+
return false, envName
281+
}
282+
defer kube.Close()
283+
284+
// Use a lightweight check via `kubectl diff --exit-code`
285+
hasChanges, err := kube.HasChanges(l.Resources)
286+
if err != nil {
287+
log.Warn().Err(err).Str("env", envName).Msg("Failed to check changes, assuming changes exist")
288+
return true, envName
289+
}
290+
291+
return hasChanges, envName
292+
}
293+
198294
// DeleteOpts specify additional properties for the Delete operation
199295
type DeleteOpts struct {
200296
ApplyBaseOpts

0 commit comments

Comments
 (0)