feat(cli): Support Server-Side Diff CLI (#23978)

Signed-off-by: Peter Jiang <peterjiang823@gmail.com>
This commit is contained in:
Peter Jiang 2025-08-12 10:02:21 -07:00 committed by GitHub
parent 24b0ecc657
commit 6a2077642e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1791 additions and 232 deletions

64
assets/swagger.json generated
View file

@ -374,6 +374,56 @@
}
}
},
"/api/v1/applications/{appName}/server-side-diff": {
"get": {
"tags": [
"ApplicationService"
],
"summary": "ServerSideDiff performs server-side diff calculation using dry-run apply",
"operationId": "ApplicationService_ServerSideDiff",
"parameters": [
{
"type": "string",
"name": "appName",
"in": "path",
"required": true
},
{
"type": "string",
"name": "appNamespace",
"in": "query"
},
{
"type": "string",
"name": "project",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"name": "targetManifests",
"in": "query"
}
],
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/applicationApplicationServerSideDiffResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/runtimeError"
}
}
}
}
},
"/api/v1/applications/{application.metadata.name}": {
"put": {
"tags": [
@ -5019,6 +5069,20 @@
}
}
},
"applicationApplicationServerSideDiffResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/v1alpha1ResourceDiff"
}
},
"modified": {
"type": "boolean"
}
}
},
"applicationApplicationSyncRequest": {
"type": "object",
"title": "ApplicationSyncRequest is a request to apply the config state to live state",

View file

@ -39,9 +39,13 @@ import (
"github.com/argoproj/argo-cd/v3/cmd/argocd/commands/headless"
"github.com/argoproj/argo-cd/v3/cmd/argocd/commands/utils"
cmdutil "github.com/argoproj/argo-cd/v3/cmd/util"
argocommon "github.com/argoproj/argo-cd/v3/common"
"github.com/argoproj/argo-cd/v3/controller"
argocdclient "github.com/argoproj/argo-cd/v3/pkg/apiclient"
"github.com/argoproj/argo-cd/v3/pkg/apiclient/application"
resourceutil "github.com/argoproj/gitops-engine/pkg/sync/resource"
clusterpkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/cluster"
projectpkg "github.com/argoproj/argo-cd/v3/pkg/apiclient/project"
"github.com/argoproj/argo-cd/v3/pkg/apiclient/settings"
@ -1282,6 +1286,7 @@ func NewApplicationDiffCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co
revision string
localRepoRoot string
serverSideGenerate bool
serverSideDiff bool
localIncludes []string
appNamespace string
revisions []string
@ -1344,6 +1349,22 @@ func NewApplicationDiffCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co
argoSettings, err := settingsIf.Get(ctx, &settings.SettingsQuery{})
errors.CheckError(err)
diffOption := &DifferenceOption{}
hasServerSideDiffAnnotation := resourceutil.HasAnnotationOption(app, argocommon.AnnotationCompareOptions, "ServerSideDiff=true")
// Use annotation if flag not explicitly set
if !c.Flags().Changed("server-side-diff") {
serverSideDiff = hasServerSideDiffAnnotation
} else if serverSideDiff && !hasServerSideDiffAnnotation {
// Flag explicitly set to true, but app annotation is not set
fmt.Fprintf(os.Stderr, "Warning: Application does not have ServerSideDiff=true annotation.\n")
}
// Server side diff with local requires server side generate to be set as there will be a mismatch with client-generated manifests.
if serverSideDiff && local != "" && !serverSideGenerate {
log.Fatal("--server-side-diff with --local requires --server-side-generate.")
}
switch {
case app.Spec.HasMultipleSources() && len(revisions) > 0 && len(sourcePositions) > 0:
numOfSources := int64(len(app.Spec.GetSources()))
@ -1399,7 +1420,8 @@ func NewApplicationDiffCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co
}
}
proj := getProject(ctx, c, clientOpts, app.Spec.Project)
foundDiffs := findandPrintDiff(ctx, app, proj.Project, resources, argoSettings, diffOption, ignoreNormalizerOpts)
foundDiffs := findAndPrintDiff(ctx, app, proj.Project, resources, argoSettings, diffOption, ignoreNormalizerOpts, serverSideDiff, appIf, app.GetName(), app.GetNamespace())
if foundDiffs && exitCode {
os.Exit(diffExitCode)
}
@ -1413,6 +1435,7 @@ func NewApplicationDiffCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co
command.Flags().StringVar(&revision, "revision", "", "Compare live app to a particular revision")
command.Flags().StringVar(&localRepoRoot, "local-repo-root", "/", "Path to the repository root. Used together with --local allows setting the repository root")
command.Flags().BoolVar(&serverSideGenerate, "server-side-generate", false, "Used with --local, this will send your manifests to the server for diffing")
command.Flags().BoolVar(&serverSideDiff, "server-side-diff", false, "Use server-side diff to calculate the diff. This will default to true if the ServerSideDiff annotation is set on the application.")
command.Flags().StringArrayVar(&localIncludes, "local-include", []string{"*.yaml", "*.yml", "*.json"}, "Used with --server-side-generate, specify patterns of filenames to send. Matching is based on filename and not path.")
command.Flags().StringVarP(&appNamespace, "app-namespace", "N", "", "Only render the difference in namespace")
command.Flags().StringArrayVar(&revisions, "revisions", []string{}, "Show manifests at specific revisions for source position in source-positions")
@ -1422,6 +1445,101 @@ func NewApplicationDiffCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co
return command
}
// printResourceDiff prints the diff header and calls cli.PrintDiff for a resource
func printResourceDiff(group, kind, namespace, name string, live, target *unstructured.Unstructured) {
fmt.Printf("\n===== %s/%s %s/%s ======\n", group, kind, namespace, name)
_ = cli.PrintDiff(name, live, target)
}
// findAndPrintServerSideDiff performs a server-side diff by making requests to the api server and prints the response
func findAndPrintServerSideDiff(ctx context.Context, app *argoappv1.Application, items []objKeyLiveTarget, resources *application.ManagedResourcesResponse, appIf application.ApplicationServiceClient, appName, appNs string) bool {
// Process each item for server-side diff
foundDiffs := false
for _, item := range items {
if item.target != nil && hook.IsHook(item.target) || item.live != nil && hook.IsHook(item.live) {
continue
}
// For server-side diff, we need to create aligned arrays for this specific resource
var liveResource *argoappv1.ResourceDiff
var targetManifest string
if item.live != nil {
for _, res := range resources.Items {
if res.Group == item.key.Group && res.Kind == item.key.Kind &&
res.Namespace == item.key.Namespace && res.Name == item.key.Name {
liveResource = res
break
}
}
}
if liveResource == nil {
// Create empty live resource for creation case
liveResource = &argoappv1.ResourceDiff{
Group: item.key.Group,
Kind: item.key.Kind,
Namespace: item.key.Namespace,
Name: item.key.Name,
LiveState: "",
TargetState: "",
Modified: true,
}
}
if item.target != nil {
jsonBytes, err := json.Marshal(item.target)
if err != nil {
errors.CheckError(fmt.Errorf("error marshaling target object: %w", err))
}
targetManifest = string(jsonBytes)
}
// Call server-side diff for this individual resource
serverSideDiffQuery := &application.ApplicationServerSideDiffQuery{
AppName: &appName,
AppNamespace: &appNs,
Project: &app.Spec.Project,
LiveResources: []*argoappv1.ResourceDiff{liveResource},
TargetManifests: []string{targetManifest},
}
serverSideDiffRes, err := appIf.ServerSideDiff(ctx, serverSideDiffQuery)
if err != nil {
errors.CheckError(err)
}
// Extract diff for this resource
for _, resultItem := range serverSideDiffRes.Items {
if resultItem.Hook || (!resultItem.Modified && resultItem.TargetState != "" && resultItem.LiveState != "") {
continue
}
if resultItem.Modified || resultItem.TargetState == "" || resultItem.LiveState == "" {
var live, target *unstructured.Unstructured
if resultItem.TargetState != "" && resultItem.TargetState != "null" {
target = &unstructured.Unstructured{}
err = json.Unmarshal([]byte(resultItem.TargetState), target)
errors.CheckError(err)
}
if resultItem.LiveState != "" && resultItem.LiveState != "null" {
live = &unstructured.Unstructured{}
err = json.Unmarshal([]byte(resultItem.LiveState), live)
errors.CheckError(err)
}
// Print resulting diff for this resource
foundDiffs = true
printResourceDiff(resultItem.Group, resultItem.Kind, resultItem.Namespace, resultItem.Name, live, target)
}
}
}
return foundDiffs
}
// DifferenceOption struct to store diff options
type DifferenceOption struct {
local string
@ -1433,47 +1551,15 @@ type DifferenceOption struct {
revisions []string
}
// findandPrintDiff ... Prints difference between application current state and state stored in git or locally, returns boolean as true if difference is found else returns false
func findandPrintDiff(ctx context.Context, app *argoappv1.Application, proj *argoappv1.AppProject, resources *application.ManagedResourcesResponse, argoSettings *settings.Settings, diffOptions *DifferenceOption, ignoreNormalizerOpts normalizers.IgnoreNormalizerOpts) bool {
// findAndPrintDiff ... Prints difference between application current state and state stored in git or locally, returns boolean as true if difference is found else returns false
func findAndPrintDiff(ctx context.Context, app *argoappv1.Application, proj *argoappv1.AppProject, resources *application.ManagedResourcesResponse, argoSettings *settings.Settings, diffOptions *DifferenceOption, ignoreNormalizerOpts normalizers.IgnoreNormalizerOpts, useServerSideDiff bool, appIf application.ApplicationServiceClient, appName, appNs string) bool {
var foundDiffs bool
liveObjs, err := cmdutil.LiveObjects(resources.Items)
items, err := prepareObjectsForDiff(ctx, app, proj, resources, argoSettings, diffOptions)
errors.CheckError(err)
items := make([]objKeyLiveTarget, 0)
switch {
case diffOptions.local != "":
localObjs := groupObjsByKey(getLocalObjects(ctx, app, proj, diffOptions.local, diffOptions.localRepoRoot, argoSettings.AppLabelKey, diffOptions.cluster.Info.ServerVersion, diffOptions.cluster.Info.APIVersions, argoSettings.KustomizeOptions, argoSettings.TrackingMethod), liveObjs, app.Spec.Destination.Namespace)
items = groupObjsForDiff(resources, localObjs, items, argoSettings, app.InstanceName(argoSettings.ControllerNamespace), app.Spec.Destination.Namespace)
case diffOptions.revision != "" || len(diffOptions.revisions) > 0:
var unstructureds []*unstructured.Unstructured
for _, mfst := range diffOptions.res.Manifests {
obj, err := argoappv1.UnmarshalToUnstructured(mfst)
errors.CheckError(err)
unstructureds = append(unstructureds, obj)
}
groupedObjs := groupObjsByKey(unstructureds, liveObjs, app.Spec.Destination.Namespace)
items = groupObjsForDiff(resources, groupedObjs, items, argoSettings, app.InstanceName(argoSettings.ControllerNamespace), app.Spec.Destination.Namespace)
case diffOptions.serversideRes != nil:
var unstructureds []*unstructured.Unstructured
for _, mfst := range diffOptions.serversideRes.Manifests {
obj, err := argoappv1.UnmarshalToUnstructured(mfst)
errors.CheckError(err)
unstructureds = append(unstructureds, obj)
}
groupedObjs := groupObjsByKey(unstructureds, liveObjs, app.Spec.Destination.Namespace)
items = groupObjsForDiff(resources, groupedObjs, items, argoSettings, app.InstanceName(argoSettings.ControllerNamespace), app.Spec.Destination.Namespace)
default:
for i := range resources.Items {
res := resources.Items[i]
live := &unstructured.Unstructured{}
err := json.Unmarshal([]byte(res.NormalizedLiveState), &live)
errors.CheckError(err)
target := &unstructured.Unstructured{}
err = json.Unmarshal([]byte(res.TargetState), &target)
errors.CheckError(err)
items = append(items, objKeyLiveTarget{kube.NewResourceKey(res.Group, res.Kind, res.Namespace, res.Name), live, target})
}
if useServerSideDiff {
return findAndPrintServerSideDiff(ctx, app, items, resources, appIf, appName, appNs)
}
for _, item := range items {
@ -1500,7 +1586,6 @@ func findandPrintDiff(ctx context.Context, app *argoappv1.Application, proj *arg
errors.CheckError(err)
if diffRes.Modified || item.target == nil || item.live == nil {
fmt.Printf("\n===== %s/%s %s/%s ======\n", item.key.Group, item.key.Kind, item.key.Namespace, item.key.Name)
var live *unstructured.Unstructured
var target *unstructured.Unstructured
if item.target != nil && item.live != nil {
@ -1512,10 +1597,8 @@ func findandPrintDiff(ctx context.Context, app *argoappv1.Application, proj *arg
live = item.live
target = item.target
}
if !foundDiffs {
foundDiffs = true
}
_ = cli.PrintDiff(item.key.Name, live, target)
foundDiffs = true
printResourceDiff(item.key.Group, item.key.Kind, item.key.Namespace, item.key.Name, live, target)
}
}
return foundDiffs
@ -2297,7 +2380,11 @@ func NewApplicationSyncCommand(clientOpts *argocdclient.ClientOptions) *cobra.Co
fmt.Printf("====== Previewing differences between live and desired state of application %s ======\n", appQualifiedName)
proj := getProject(ctx, c, clientOpts, app.Spec.Project)
foundDiffs = findandPrintDiff(ctx, app, proj.Project, resources, argoSettings, diffOption, ignoreNormalizerOpts)
// Check if application has ServerSideDiff annotation
serverSideDiff := resourceutil.HasAnnotationOption(app, argocommon.AnnotationCompareOptions, "ServerSideDiff=true")
foundDiffs = findAndPrintDiff(ctx, app, proj.Project, resources, argoSettings, diffOption, ignoreNormalizerOpts, serverSideDiff, appIf, appName, appNs)
if !foundDiffs {
fmt.Printf("====== No Differences found ======\n")
// if no differences found, then no need to sync
@ -3520,3 +3607,60 @@ func NewApplicationConfirmDeletionCommand(clientOpts *argocdclient.ClientOptions
command.Flags().StringVarP(&appNamespace, "app-namespace", "N", "", "Namespace of the target application where the source will be appended")
return command
}
// prepareObjectsForDiff prepares objects for diffing using the switch statement
// to handle different diff options and building the objKeyLiveTarget items
func prepareObjectsForDiff(ctx context.Context, app *argoappv1.Application, proj *argoappv1.AppProject, resources *application.ManagedResourcesResponse, argoSettings *settings.Settings, diffOptions *DifferenceOption) ([]objKeyLiveTarget, error) {
liveObjs, err := cmdutil.LiveObjects(resources.Items)
if err != nil {
return nil, err
}
items := make([]objKeyLiveTarget, 0)
switch {
case diffOptions.local != "":
localObjs := groupObjsByKey(getLocalObjects(ctx, app, proj, diffOptions.local, diffOptions.localRepoRoot, argoSettings.AppLabelKey, diffOptions.cluster.Info.ServerVersion, diffOptions.cluster.Info.APIVersions, argoSettings.KustomizeOptions, argoSettings.TrackingMethod), liveObjs, app.Spec.Destination.Namespace)
items = groupObjsForDiff(resources, localObjs, items, argoSettings, app.InstanceName(argoSettings.ControllerNamespace), app.Spec.Destination.Namespace)
case diffOptions.revision != "" || len(diffOptions.revisions) > 0:
var unstructureds []*unstructured.Unstructured
for _, mfst := range diffOptions.res.Manifests {
obj, err := argoappv1.UnmarshalToUnstructured(mfst)
if err != nil {
return nil, err
}
unstructureds = append(unstructureds, obj)
}
groupedObjs := groupObjsByKey(unstructureds, liveObjs, app.Spec.Destination.Namespace)
items = groupObjsForDiff(resources, groupedObjs, items, argoSettings, app.InstanceName(argoSettings.ControllerNamespace), app.Spec.Destination.Namespace)
case diffOptions.serversideRes != nil:
var unstructureds []*unstructured.Unstructured
for _, mfst := range diffOptions.serversideRes.Manifests {
obj, err := argoappv1.UnmarshalToUnstructured(mfst)
if err != nil {
return nil, err
}
unstructureds = append(unstructureds, obj)
}
groupedObjs := groupObjsByKey(unstructureds, liveObjs, app.Spec.Destination.Namespace)
items = groupObjsForDiff(resources, groupedObjs, items, argoSettings, app.InstanceName(argoSettings.ControllerNamespace), app.Spec.Destination.Namespace)
default:
for i := range resources.Items {
res := resources.Items[i]
live := &unstructured.Unstructured{}
err := json.Unmarshal([]byte(res.NormalizedLiveState), &live)
if err != nil {
return nil, err
}
target := &unstructured.Unstructured{}
err = json.Unmarshal([]byte(res.TargetState), &target)
if err != nil {
return nil, err
}
items = append(items, objKeyLiveTarget{kube.NewResourceKey(res.Group, res.Kind, res.Namespace, res.Name), live, target})
}
}
return items, nil
}

View file

@ -2253,6 +2253,10 @@ func (c *fakeAppServiceClient) ListResourceLinks(_ context.Context, _ *applicati
return nil, nil
}
func (c *fakeAppServiceClient) ServerSideDiff(_ context.Context, _ *applicationpkg.ApplicationServerSideDiffQuery, _ ...grpc.CallOption) (*applicationpkg.ApplicationServerSideDiffResponse, error) {
return nil, nil
}
type fakeAcdClient struct {
simulateTimeout uint
}

View file

@ -30,6 +30,7 @@ argocd app diff APPNAME [flags]
--refresh Refresh application data when retrieving
--revision string Compare live app to a particular revision
--revisions stringArray Show manifests at specific revisions for source position in source-positions
--server-side-diff Use server-side diff to calculate the diff. This will default to true if the ServerSideDiff annotation is set on the application.
--server-side-generate Used with --local, this will send your manifests to the server for diffing
--source-names stringArray List of source names. Default is an empty array.
--source-positions int64Slice List of source positions. Default is empty array. Counting start at 1. (default [])

File diff suppressed because it is too large Load diff

View file

@ -1223,6 +1223,78 @@ func local_request_ApplicationService_ManagedResources_0(ctx context.Context, ma
}
var (
filter_ApplicationService_ServerSideDiff_0 = &utilities.DoubleArray{Encoding: map[string]int{"appName": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}}
)
func request_ApplicationService_ServerSideDiff_0(ctx context.Context, marshaler runtime.Marshaler, client ApplicationServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var protoReq ApplicationServerSideDiffQuery
var metadata runtime.ServerMetadata
var (
val string
ok bool
err error
_ = err
)
val, ok = pathParams["appName"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "appName")
}
protoReq.AppName, err = runtime.StringP(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "appName", err)
}
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_ApplicationService_ServerSideDiff_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.ServerSideDiff(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_ApplicationService_ServerSideDiff_0(ctx context.Context, marshaler runtime.Marshaler, server ApplicationServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var protoReq ApplicationServerSideDiffQuery
var metadata runtime.ServerMetadata
var (
val string
ok bool
err error
_ = err
)
val, ok = pathParams["appName"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "appName")
}
protoReq.AppName, err = runtime.StringP(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "appName", err)
}
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_ApplicationService_ServerSideDiff_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.ServerSideDiff(ctx, &protoReq)
return msg, metadata, err
}
var (
filter_ApplicationService_ResourceTree_0 = &utilities.DoubleArray{Encoding: map[string]int{"applicationName": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}}
)
@ -2557,6 +2629,29 @@ func RegisterApplicationServiceHandlerServer(ctx context.Context, mux *runtime.S
})
mux.Handle("GET", pattern_ApplicationService_ServerSideDiff_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
rctx, err := runtime.AnnotateIncomingContext(ctx, mux, req)
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_ApplicationService_ServerSideDiff_0(rctx, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
ctx = runtime.NewServerMetadataContext(ctx, md)
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
forward_ApplicationService_ServerSideDiff_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle("GET", pattern_ApplicationService_ResourceTree_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -3212,6 +3307,26 @@ func RegisterApplicationServiceHandlerClient(ctx context.Context, mux *runtime.S
})
mux.Handle("GET", pattern_ApplicationService_ServerSideDiff_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
rctx, err := runtime.AnnotateContext(ctx, mux, req)
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_ApplicationService_ServerSideDiff_0(rctx, inboundMarshaler, client, req, pathParams)
ctx = runtime.NewServerMetadataContext(ctx, md)
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
forward_ApplicationService_ServerSideDiff_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle("GET", pattern_ApplicationService_ResourceTree_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -3530,6 +3645,8 @@ var (
pattern_ApplicationService_ManagedResources_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "applications", "applicationName", "managed-resources"}, "", runtime.AssumeColonVerbOpt(true)))
pattern_ApplicationService_ServerSideDiff_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "applications", "appName", "server-side-diff"}, "", runtime.AssumeColonVerbOpt(true)))
pattern_ApplicationService_ResourceTree_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "applications", "applicationName", "resource-tree"}, "", runtime.AssumeColonVerbOpt(true)))
pattern_ApplicationService_WatchResourceTree_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5}, []string{"api", "v1", "stream", "applications", "applicationName", "resource-tree"}, "", runtime.AssumeColonVerbOpt(true)))
@ -3594,6 +3711,8 @@ var (
forward_ApplicationService_ManagedResources_0 = runtime.ForwardResponseMessage
forward_ApplicationService_ServerSideDiff_0 = runtime.ForwardResponseMessage
forward_ApplicationService_ResourceTree_0 = runtime.ForwardResponseMessage
forward_ApplicationService_WatchResourceTree_0 = runtime.ForwardResponseStream

View file

@ -13,12 +13,11 @@ import (
"strings"
"time"
"github.com/argoproj/gitops-engine/pkg/health"
cacheutil "github.com/argoproj/argo-cd/v3/util/cache"
kubecache "github.com/argoproj/gitops-engine/pkg/cache"
"github.com/argoproj/gitops-engine/pkg/diff"
"github.com/argoproj/gitops-engine/pkg/health"
"github.com/argoproj/gitops-engine/pkg/sync/common"
"github.com/argoproj/gitops-engine/pkg/utils/kube"
"github.com/argoproj/gitops-engine/pkg/utils/text"
@ -44,6 +43,7 @@ import (
argocommon "github.com/argoproj/argo-cd/v3/common"
"github.com/argoproj/argo-cd/v3/pkg/apiclient/application"
"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
appclientset "github.com/argoproj/argo-cd/v3/pkg/client/clientset/versioned"
applisters "github.com/argoproj/argo-cd/v3/pkg/client/listers/application/v1alpha1"
"github.com/argoproj/argo-cd/v3/reposerver/apiclient"
@ -63,7 +63,12 @@ import (
"github.com/argoproj/argo-cd/v3/util/session"
"github.com/argoproj/argo-cd/v3/util/settings"
resourceutil "github.com/argoproj/gitops-engine/pkg/sync/resource"
applicationType "github.com/argoproj/argo-cd/v3/pkg/apis/application"
argodiff "github.com/argoproj/argo-cd/v3/util/argo/diff"
"github.com/argoproj/argo-cd/v3/util/argo/normalizers"
kubeutil "github.com/argoproj/argo-cd/v3/util/kube"
)
type AppResourceTreeFn func(ctx context.Context, app *v1alpha1.Application) (*v1alpha1.ApplicationTree, error)
@ -243,6 +248,9 @@ func (s *Server) getAppEnforceRBAC(ctx context.Context, action, project, namespa
func (s *Server) getApplicationEnforceRBACInformer(ctx context.Context, action, project, namespace, name string) (*v1alpha1.Application, *v1alpha1.AppProject, error) {
namespaceOrDefault := s.appNamespaceOrDefault(namespace)
return s.getAppEnforceRBAC(ctx, action, project, namespaceOrDefault, name, func() (*v1alpha1.Application, error) {
if !s.isNamespaceEnabled(namespaceOrDefault) {
return nil, security.NamespaceNotPermittedError(namespaceOrDefault)
}
return s.appLister.Applications(namespaceOrDefault).Get(name)
})
}
@ -2824,3 +2832,171 @@ func getProjectsFromApplicationQuery(q application.ApplicationQuery) []string {
}
return q.Projects
}
// ServerSideDiff gets the destination cluster and creates a server-side dry run applier and performs the diff
// It returns the diff result in the form of a list of ResourceDiffs.
func (s *Server) ServerSideDiff(ctx context.Context, q *application.ApplicationServerSideDiffQuery) (*application.ApplicationServerSideDiffResponse, error) {
a, _, err := s.getApplicationEnforceRBACInformer(ctx, rbac.ActionGet, q.GetProject(), q.GetAppNamespace(), q.GetAppName())
if err != nil {
return nil, fmt.Errorf("error getting application: %w", err)
}
argoSettings, err := s.settingsMgr.GetSettings()
if err != nil {
return nil, fmt.Errorf("error getting ArgoCD settings: %w", err)
}
resourceOverrides, err := s.settingsMgr.GetResourceOverrides()
if err != nil {
return nil, fmt.Errorf("error getting resource overrides: %w", err)
}
// Convert to map format expected by DiffConfigBuilder
overrides := make(map[string]v1alpha1.ResourceOverride)
for k, v := range resourceOverrides {
overrides[k] = v
}
// Get cluster connection for server-side dry run
cluster, err := argo.GetDestinationCluster(ctx, a.Spec.Destination, s.db)
if err != nil {
return nil, fmt.Errorf("error getting destination cluster: %w", err)
}
clusterConfig, err := cluster.RawRestConfig()
if err != nil {
return nil, fmt.Errorf("error getting cluster raw REST config: %w", err)
}
// Create server-side diff dry run applier
openAPISchema, gvkParser, err := s.kubectl.LoadOpenAPISchema(clusterConfig)
if err != nil {
return nil, fmt.Errorf("failed to get OpenAPI schema: %w", err)
}
applier, cleanup, err := kubeutil.ManageServerSideDiffDryRuns(clusterConfig, openAPISchema, func(_ string) (kube.CleanupFunc, error) {
return func() {}, nil
})
if err != nil {
return nil, fmt.Errorf("error creating server-side dry run applier: %w", err)
}
defer cleanup()
dryRunner := diff.NewK8sServerSideDryRunner(applier)
appLabelKey, err := s.settingsMgr.GetAppInstanceLabelKey()
if err != nil {
return nil, fmt.Errorf("error getting app instance label key: %w", err)
}
// Build diff config like the CLI does, but with server-side diff enabled
ignoreAggregatedRoles := false
diffConfig, err := argodiff.NewDiffConfigBuilder().
WithDiffSettings(a.Spec.IgnoreDifferences, overrides, ignoreAggregatedRoles, normalizers.IgnoreNormalizerOpts{}).
WithTracking(appLabelKey, argoSettings.TrackingMethod).
WithNoCache().
WithManager(argocommon.ArgoCDSSAManager).
WithServerSideDiff(true).
WithServerSideDryRunner(dryRunner).
WithGVKParser(gvkParser).
WithIgnoreMutationWebhook(!resourceutil.HasAnnotationOption(a, argocommon.AnnotationCompareOptions, "IncludeMutationWebhook=true")).
Build()
if err != nil {
return nil, fmt.Errorf("error building diff config: %w", err)
}
// Convert live resources to unstructured objects
liveObjs := make([]*unstructured.Unstructured, 0, len(q.GetLiveResources()))
for _, liveResource := range q.GetLiveResources() {
if liveResource.LiveState != "" && liveResource.LiveState != "null" {
liveObj := &unstructured.Unstructured{}
err := json.Unmarshal([]byte(liveResource.LiveState), liveObj)
if err != nil {
return nil, fmt.Errorf("error unmarshaling live state for %s/%s: %w", liveResource.Kind, liveResource.Name, err)
}
liveObjs = append(liveObjs, liveObj)
} else {
liveObjs = append(liveObjs, nil)
}
}
// Convert target manifests to unstructured objects
targetObjs := make([]*unstructured.Unstructured, 0, len(q.GetTargetManifests()))
for i, manifestStr := range q.GetTargetManifests() {
obj, err := v1alpha1.UnmarshalToUnstructured(manifestStr)
if err != nil {
return nil, fmt.Errorf("error unmarshaling target manifest %d: %w", i, err)
}
targetObjs = append(targetObjs, obj)
}
diffResults, err := argodiff.StateDiffs(liveObjs, targetObjs, diffConfig)
if err != nil {
return nil, fmt.Errorf("error performing state diffs: %w", err)
}
// Convert StateDiffs results to ResourceDiff format for API response
responseDiffs := make([]*v1alpha1.ResourceDiff, 0, len(diffResults.Diffs))
modified := false
for i, diffRes := range diffResults.Diffs {
if diffRes.Modified {
modified = true
}
// Extract resource metadata for the diff result. Resources should be pre-aligned by the CLI.
var group, kind, namespace, name string
var hook bool
var resourceVersion string
// Extract resource metadata for the ResourceDiff response. The CLI sends aligned arrays
// of live resources and target manifests, but individual resources may only exist in one
// array depending on the operation
switch {
case i < len(q.GetLiveResources()):
// A live resource exists at this index
lr := q.GetLiveResources()[i]
group = lr.Group
kind = lr.Kind
namespace = lr.Namespace
name = lr.Name
hook = lr.Hook
resourceVersion = lr.ResourceVersion
case i < len(targetObjs) && targetObjs[i] != nil:
// A target resource exists at this index, but no live resource exists at this index
obj := targetObjs[i]
group = obj.GroupVersionKind().Group
kind = obj.GroupVersionKind().Kind
namespace = obj.GetNamespace()
name = obj.GetName()
hook = false
resourceVersion = ""
default:
return nil, fmt.Errorf("diff result index %d out of bounds: live resources (%d), target objects (%d)",
i, len(q.GetLiveResources()), len(targetObjs))
}
// Create ResourceDiff with StateDiffs results
// TargetState = PredictedLive (what the target should be after applying)
// LiveState = NormalizedLive (current normalized live state)
responseDiffs = append(responseDiffs, &v1alpha1.ResourceDiff{
Group: group,
Kind: kind,
Namespace: namespace,
Name: name,
TargetState: string(diffRes.PredictedLive),
LiveState: string(diffRes.NormalizedLive),
Diff: "", // Diff string is generated client-side
Hook: hook,
Modified: diffRes.Modified,
ResourceVersion: resourceVersion,
})
}
log.Infof("ServerSideDiff completed with %d results, overall modified: %t", len(responseDiffs), modified)
return &application.ApplicationServerSideDiffResponse{
Items: responseDiffs,
Modified: &modified,
}, nil
}

View file

@ -318,6 +318,19 @@ message ManagedResourcesResponse {
repeated github.com.argoproj.argo_cd.v3.pkg.apis.application.v1alpha1.ResourceDiff items = 1;
}
message ApplicationServerSideDiffQuery {
required string appName = 1;
optional string appNamespace = 2;
optional string project = 3;
repeated github.com.argoproj.argo_cd.v3.pkg.apis.application.v1alpha1.ResourceDiff liveResources = 4;
repeated string targetManifests = 5;
}
message ApplicationServerSideDiffResponse {
repeated github.com.argoproj.argo_cd.v3.pkg.apis.application.v1alpha1.ResourceDiff items = 1;
required bool modified = 2;
}
message LinkInfo {
required string title = 1;
required string url = 2;
@ -442,6 +455,11 @@ service ApplicationService {
option (google.api.http).get = "/api/v1/applications/{applicationName}/managed-resources";
}
// ServerSideDiff performs server-side diff calculation using dry-run apply
rpc ServerSideDiff(ApplicationServerSideDiffQuery) returns (ApplicationServerSideDiffResponse) {
option (google.api.http).get = "/api/v1/applications/{appName}/server-side-diff";
}
// ResourceTree returns resource tree
rpc ResourceTree(ResourcesQuery) returns (github.com.argoproj.argo_cd.v3.pkg.apis.application.v1alpha1.ApplicationTree) {
option (google.api.http).get = "/api/v1/applications/{applicationName}/resource-tree";

View file

@ -3590,3 +3590,145 @@ func Test_DeepCopyInformers(t *testing.T) {
assert.NotSame(t, p, &spList[i])
}
}
func TestServerSideDiff(t *testing.T) {
// Create test projects (avoid "default" which is already created by newTestAppServerWithEnforcerConfigure)
testProj := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{Name: "test-project", Namespace: testNamespace},
Spec: v1alpha1.AppProjectSpec{
SourceRepos: []string{"*"},
Destinations: []v1alpha1.ApplicationDestination{{Server: "*", Namespace: "*"}},
},
}
forbiddenProj := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{Name: "forbidden-project", Namespace: testNamespace},
Spec: v1alpha1.AppProjectSpec{
SourceRepos: []string{"*"},
Destinations: []v1alpha1.ApplicationDestination{{Server: "*", Namespace: "*"}},
},
}
// Create test applications that will exist in the server
testApp := newTestApp(func(app *v1alpha1.Application) {
app.Name = "test-app"
app.Namespace = testNamespace
app.Spec.Project = "test-project"
})
forbiddenApp := newTestApp(func(app *v1alpha1.Application) {
app.Name = "forbidden-app"
app.Namespace = testNamespace
app.Spec.Project = "forbidden-project"
})
appServer := newTestAppServer(t, testProj, forbiddenProj, testApp, forbiddenApp)
t.Run("InputValidation", func(t *testing.T) {
// Test missing application name
query := &application.ApplicationServerSideDiffQuery{
AppName: ptr.To(""), // Empty name instead of nil
AppNamespace: ptr.To(testNamespace),
Project: ptr.To("test-project"),
LiveResources: []*v1alpha1.ResourceDiff{},
TargetManifests: []string{},
}
_, err := appServer.ServerSideDiff(t.Context(), query)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
// Test nil application name
queryNil := &application.ApplicationServerSideDiffQuery{
AppName: nil,
AppNamespace: ptr.To(testNamespace),
Project: ptr.To("test-project"),
LiveResources: []*v1alpha1.ResourceDiff{},
TargetManifests: []string{},
}
_, err = appServer.ServerSideDiff(t.Context(), queryNil)
assert.Error(t, err)
// Should get an error when name is nil
})
t.Run("InvalidManifest", func(t *testing.T) {
// Test error handling for malformed JSON in target manifests
query := &application.ApplicationServerSideDiffQuery{
AppName: ptr.To("test-app"),
AppNamespace: ptr.To(testNamespace),
Project: ptr.To("test-project"),
LiveResources: []*v1alpha1.ResourceDiff{},
TargetManifests: []string{`invalid json`},
}
_, err := appServer.ServerSideDiff(t.Context(), query)
// Should return error for invalid JSON
require.Error(t, err)
assert.Contains(t, err.Error(), "error unmarshaling target manifest")
})
t.Run("InvalidLiveState", func(t *testing.T) {
// Test error handling for malformed JSON in live state
liveResource := &v1alpha1.ResourceDiff{
Group: "apps",
Kind: "Deployment",
Namespace: "default",
Name: "test-deployment",
LiveState: `invalid json`,
TargetState: "",
Modified: true,
}
query := &application.ApplicationServerSideDiffQuery{
AppName: ptr.To("test-app"),
AppNamespace: ptr.To(testNamespace),
Project: ptr.To("test-project"),
LiveResources: []*v1alpha1.ResourceDiff{liveResource},
TargetManifests: []string{`{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"test"}}`},
}
_, err := appServer.ServerSideDiff(t.Context(), query)
// Should return error for invalid JSON in live state
require.Error(t, err)
assert.Contains(t, err.Error(), "error unmarshaling live state")
})
t.Run("EmptyRequest", func(t *testing.T) {
// Test with empty resources - should succeed without errors but no diffs
query := &application.ApplicationServerSideDiffQuery{
AppName: ptr.To("test-app"),
AppNamespace: ptr.To(testNamespace),
Project: ptr.To("test-project"),
LiveResources: []*v1alpha1.ResourceDiff{},
TargetManifests: []string{},
}
resp, err := appServer.ServerSideDiff(t.Context(), query)
// Should succeed with empty response
require.NoError(t, err)
assert.NotNil(t, resp)
assert.False(t, *resp.Modified)
assert.Empty(t, resp.Items)
})
t.Run("MissingAppPermission", func(t *testing.T) {
// Test RBAC enforcement
query := &application.ApplicationServerSideDiffQuery{
AppName: ptr.To("nonexistent-app"),
AppNamespace: ptr.To(testNamespace),
Project: ptr.To("nonexistent-project"),
LiveResources: []*v1alpha1.ResourceDiff{},
TargetManifests: []string{},
}
_, err := appServer.ServerSideDiff(t.Context(), query)
// Should fail with permission error since nonexistent-app doesn't exist
require.Error(t, err)
assert.Contains(t, err.Error(), "application")
})
}

View file

@ -3075,3 +3075,198 @@ status:
assert.Equal(t, "2023-01-01T00:00:00Z", app.Status.Health.LastTransitionTime.UTC().Format(time.RFC3339))
})
}
// TestServerSideDiffCommand tests the --server-side-diff flag for the app diff command
func TestServerSideDiffCommand(t *testing.T) {
Given(t).
Path("two-nice-pods").
When().
CreateApp().
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(SyncStatusIs(SyncStatusCodeSynced)).
When().
// Create a diff by modifying a pod
PatchFile("pod-1.yaml", `[{"op": "add", "path": "/metadata/annotations", "value": {"test": "server-side-diff"}}]`).
AddFile("pod-3.yaml", `apiVersion: v1
kind: Pod
metadata:
name: pod-3
annotations:
new: "pod"
spec:
containers:
- name: main
image: quay.io/argoprojlabs/argocd-e2e-container:0.1
imagePullPolicy: IfNotPresent
command:
- "true"
restartPolicy: Never
`).
Refresh(RefreshTypeHard).
Then().
Expect(SyncStatusIs(SyncStatusCodeOutOfSync)).
And(func(app *Application) {
// Test regular diff command
regularOutput, err := fixture.RunCli("app", "diff", app.Name)
require.Error(t, err) // diff command returns non-zero exit code when differences found
assert.Contains(t, regularOutput, "===== /Pod")
assert.Contains(t, regularOutput, "pod-1")
assert.Contains(t, regularOutput, "pod-3")
// Test server-side diff command
serverSideOutput, err := fixture.RunCli("app", "diff", app.Name, "--server-side-diff")
require.Error(t, err) // diff command returns non-zero exit code when differences found
assert.Contains(t, serverSideOutput, "===== /Pod")
assert.Contains(t, serverSideOutput, "pod-1")
assert.Contains(t, serverSideOutput, "pod-3")
// Both outputs should contain similar resource headers
assert.Contains(t, regularOutput, "test: server-side-diff")
assert.Contains(t, serverSideOutput, "test: server-side-diff")
assert.Contains(t, regularOutput, "new: pod")
assert.Contains(t, serverSideOutput, "new: pod")
})
}
// TestServerSideDiffWithSyncedApp tests server-side diff when app is already synced (no differences)
func TestServerSideDiffWithSyncedApp(t *testing.T) {
Given(t).
Path("guestbook").
When().
CreateApp().
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(SyncStatusIs(SyncStatusCodeSynced)).
And(func(app *Application) {
// Test regular diff command with synced app
regularOutput, err := fixture.RunCli("app", "diff", app.Name)
require.NoError(t, err) // no differences, should return 0
// Test server-side diff command with synced app
serverSideOutput, err := fixture.RunCli("app", "diff", app.Name, "--server-side-diff")
require.NoError(t, err) // no differences, should return 0
// Both should produce similar output (minimal/no diff output)
// The exact output may vary, but both should succeed without errors
assert.NotContains(t, regularOutput, "===== ")
assert.NotContains(t, serverSideOutput, "===== ")
})
}
// TestServerSideDiffWithRevision tests server-side diff with a specific revision
func TestServerSideDiffWithRevision(t *testing.T) {
Given(t).
Path("two-nice-pods").
When().
CreateApp().
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(SyncStatusIs(SyncStatusCodeSynced)).
When().
PatchFile("pod-1.yaml", `[{"op": "add", "path": "/metadata/labels", "value": {"version": "v1.1"}}]`).
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
And(func(app *Application) {
// Get the current revision
currentRevision := ""
if len(app.Status.History) > 0 {
currentRevision = app.Status.History[len(app.Status.History)-1].Revision
}
if currentRevision != "" {
// Test server-side diff with current revision (should show no differences)
output, err := fixture.RunCli("app", "diff", app.Name, "--server-side-diff", "--revision", currentRevision)
require.NoError(t, err) // no differences expected
assert.NotContains(t, output, "===== ")
}
})
}
// TestServerSideDiffErrorHandling tests error scenarios for server-side diff
func TestServerSideDiffErrorHandling(t *testing.T) {
Given(t).
Path("two-nice-pods").
When().
CreateApp().
Then().
And(func(_ *Application) {
// Test server-side diff with non-existent app should fail gracefully
_, err := fixture.RunCli("app", "diff", "non-existent-app", "--server-side-diff")
require.Error(t, err)
// Error occurred as expected - this verifies the command fails gracefully
})
}
// TestServerSideDiffWithLocal tests server-side diff with --local flag
func TestServerSideDiffWithLocal(t *testing.T) {
Given(t).
Path("guestbook").
When().
CreateApp().
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(SyncStatusIs(SyncStatusCodeSynced)).
And(func(_ *Application) {
// Modify the live deployment in the cluster to create differences
// Apply patches to the deployment
_, err := fixture.KubeClientset.AppsV1().Deployments(fixture.DeploymentNamespace()).Patch(t.Context(),
"guestbook-ui", types.JSONPatchType, []byte(`[
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "LOCAL_CHANGE", "value": "true"}]},
{"op": "replace", "path": "/spec/replicas", "value": 2}
]`), metav1.PatchOptions{})
require.NoError(t, err)
// Verify the patch was applied by reading back the deployment
modifiedDeployment, err := fixture.KubeClientset.AppsV1().Deployments(fixture.DeploymentNamespace()).Get(t.Context(), "guestbook-ui", metav1.GetOptions{})
require.NoError(t, err)
assert.Equal(t, int32(2), *modifiedDeployment.Spec.Replicas, "Replica count should be updated to 2")
assert.Len(t, modifiedDeployment.Spec.Template.Spec.Containers[0].Env, 1, "Should have one environment variable")
assert.Equal(t, "LOCAL_CHANGE", modifiedDeployment.Spec.Template.Spec.Containers[0].Env[0].Name)
}).
When().
Refresh(RefreshTypeNormal).
Then().
Expect(SyncStatusIs(SyncStatusCodeOutOfSync)).
And(func(app *Application) {
// Test regular diff with --local (add --server-side-generate to avoid deprecation warning)
regularOutput, err := fixture.RunCli("app", "diff", app.Name, "--local", "testdata", "--server-side-generate")
require.Error(t, err) // diff command returns non-zero exit code when differences found
assert.Contains(t, regularOutput, "===== apps/Deployment")
assert.Contains(t, regularOutput, "guestbook-ui")
assert.Contains(t, regularOutput, "replicas:")
// Test server-side diff with --local (add --server-side-generate for consistency)
serverSideOutput, err := fixture.RunCli("app", "diff", app.Name, "--server-side-diff", "--local", "testdata", "--server-side-generate")
require.Error(t, err) // diff command returns non-zero exit code when differences found
assert.Contains(t, serverSideOutput, "===== apps/Deployment")
assert.Contains(t, serverSideOutput, "guestbook-ui")
assert.Contains(t, serverSideOutput, "replicas:")
// Both outputs should show similar differences
assert.Contains(t, regularOutput, "replicas: 2")
assert.Contains(t, serverSideOutput, "replicas: 2")
})
}
func TestServerSideDiffWithLocalValidation(t *testing.T) {
Given(t).
Path("guestbook").
When().
CreateApp().
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(SyncStatusIs(SyncStatusCodeSynced)).
And(func(app *Application) {
// Test that --server-side-diff with --local without --server-side-generate fails with proper error
_, err := fixture.RunCli("app", "diff", app.Name, "--server-side-diff", "--local", "testdata")
require.Error(t, err)
assert.Contains(t, err.Error(), "--server-side-diff with --local requires --server-side-generate")
})
}