diff --git a/common/common.go b/common/common.go index f43394a22e5e6..4c5e428dadc78 100644 --- a/common/common.go +++ b/common/common.go @@ -196,6 +196,9 @@ const ( // AnnotationCompareOptions is a comma-separated list of options for comparison AnnotationCompareOptions = "argocd.argoproj.io/compare-options" + // AnnotationClientSideApplyMigrationManager specifies a custom field manager for client-side apply migration + AnnotationClientSideApplyMigrationManager = "argocd.argoproj.io/client-side-apply-migration-manager" + // AnnotationIgnoreHealthCheck when set on an Application's immediate child indicates that its health check // can be disregarded. AnnotationIgnoreHealthCheck = "argocd.argoproj.io/ignore-healthcheck" diff --git a/controller/sync.go b/controller/sync.go index 4dfbfbb24dcb2..29e7e21a2da8d 100644 --- a/controller/sync.go +++ b/controller/sync.go @@ -284,6 +284,12 @@ func (m *appStateManager) SyncAppState(app *v1alpha1.Application, state *v1alpha prunePropagationPolicy = metav1.DeletePropagationOrphan } + clientSideApplyManager := common.DefaultClientSideApplyMigrationManager + // Check for custom field manager from application annotation + if managerValue := app.GetAnnotation(cdcommon.AnnotationClientSideApplyMigrationManager); managerValue != "" { + clientSideApplyManager = managerValue + } + openAPISchema, err := m.getOpenAPISchema(destCluster) if err != nil { state.Phase = common.OperationError @@ -376,6 +382,10 @@ func (m *appStateManager) SyncAppState(app *v1alpha1.Application, state *v1alpha sync.WithReplace(syncOp.SyncOptions.HasOption(common.SyncOptionReplace)), sync.WithServerSideApply(syncOp.SyncOptions.HasOption(common.SyncOptionServerSideApply)), sync.WithServerSideApplyManager(cdcommon.ArgoCDSSAManager), + sync.WithClientSideApplyMigration( + !syncOp.SyncOptions.HasOption(common.SyncOptionDisableClientSideApplyMigration), + clientSideApplyManager, + ), sync.WithPruneConfirmed(app.IsDeletionConfirmed(state.StartedAt.Time)), sync.WithSkipDryRunOnMissingResource(syncOp.SyncOptions.HasOption(common.SyncOptionSkipDryRunOnMissingResource)), } diff --git a/controller/sync_test.go b/controller/sync_test.go index 0ae1db268190b..02e1435c70934 100644 --- a/controller/sync_test.go +++ b/controller/sync_test.go @@ -1412,6 +1412,110 @@ func TestSyncWithImpersonate(t *testing.T) { }) } +func TestClientSideApplyMigration(t *testing.T) { + t.Parallel() + + type fixture struct { + application *v1alpha1.Application + controller *ApplicationController + } + + setup := func(disableMigration bool, customManager string) *fixture { + app := newFakeApp() + app.Status.OperationState = nil + app.Status.History = nil + + // Add sync options + if disableMigration { + app.Spec.SyncPolicy.SyncOptions = append(app.Spec.SyncPolicy.SyncOptions, "DisableClientSideApplyMigration=true") + } + + // Add custom manager annotation if specified + if customManager != "" { + app.Annotations = map[string]string{ + "argocd.argoproj.io/client-side-apply-migration-manager": customManager, + } + } + + project := &v1alpha1.AppProject{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: test.FakeArgoCDNamespace, + Name: "default", + }, + } + data := fakeData{ + apps: []runtime.Object{app, project}, + manifestResponse: &apiclient.ManifestResponse{ + Manifests: []string{}, + Namespace: test.FakeDestNamespace, + Server: test.FakeClusterURL, + Revision: "abc123", + }, + managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), + } + ctrl := newFakeController(&data, nil) + + return &fixture{ + application: app, + controller: ctrl, + } + } + + t.Run("client-side apply migration enabled by default", func(t *testing.T) { + // given + t.Parallel() + f := setup(false, "") + + // when + opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ + Sync: &v1alpha1.SyncOperation{ + Source: &v1alpha1.ApplicationSource{}, + }, + }} + f.controller.appStateManager.SyncAppState(f.application, opState) + + // then + assert.Equal(t, common.OperationSucceeded, opState.Phase) + assert.Contains(t, opState.Message, "successfully synced") + }) + + t.Run("client-side apply migration disabled", func(t *testing.T) { + // given + t.Parallel() + f := setup(true, "") + + // when + opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ + Sync: &v1alpha1.SyncOperation{ + Source: &v1alpha1.ApplicationSource{}, + }, + }} + f.controller.appStateManager.SyncAppState(f.application, opState) + + // then + assert.Equal(t, common.OperationSucceeded, opState.Phase) + assert.Contains(t, opState.Message, "successfully synced") + }) + + t.Run("client-side apply migration with custom manager", func(t *testing.T) { + // given + t.Parallel() + f := setup(false, "my-custom-manager") + + // when + opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ + Sync: &v1alpha1.SyncOperation{ + Source: &v1alpha1.ApplicationSource{}, + }, + }} + f.controller.appStateManager.SyncAppState(f.application, opState) + + // then + assert.Equal(t, common.OperationSucceeded, opState.Phase) + assert.Contains(t, opState.Message, "successfully synced") + }) +} + func dig[T any](obj any, path []any) T { i := obj diff --git a/docs/user-guide/sync-options.md b/docs/user-guide/sync-options.md index 3871c44c7ee3e..5857a83f35c32 100644 --- a/docs/user-guide/sync-options.md +++ b/docs/user-guide/sync-options.md @@ -293,6 +293,44 @@ to apply changes. Note: [`Replace=true`](#replace-resource-instead-of-applying-changes) takes precedence over `ServerSideApply=true`. +### Client-Side Apply Migration + +Argo CD supports client-side apply migration, which helps transitioning from client-side apply to server-side apply by moving a resource's managed fields from one manager to Argo CD's manager. This feature is particularly useful when you need to migrate existing resources that were created using kubectl client-side apply to server-side apply with Argo CD. + +By default, client-side apply migration is enabled. You can disable it using the sync option: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +spec: + syncPolicy: + syncOptions: + - DisableClientSideApplyMigration=true +``` + +You can specify a custom field manager for the client-side apply migration using an annotation: + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + annotations: + argocd.argoproj.io/client-side-apply-migration-manager: "my-custom-manager" +``` + +This is useful when you have other operators managing resources that are no longer in use and would like Argo CD to own all the fields for that operator. + +### How it works + +When client-side apply migration is enabled: +1. Argo CD will use the specified field manager (or default if not specified) to perform migration +2. During a server-side apply sync operation, it will: + - Perfirm a client-side-apply with the specified field manager + - Move the 'last-appled-configuration' annotation to be managed by the specified manager + - Perform the server-side apply, which will auto migrate all the fields under the manager that owns the 'last-applied-configration' annotation. + +This feature is based on Kubernetes' [client-side apply migration KEP](https://github.com/alexzielenski/enhancements/blob/03df8820b9feca6d2cab78e303c99b2c9c0c4c5c/keps/sig-cli/3517-kubectl-client-side-apply-migration/README.md), which provides the auto migration from client-side to server-side apply. + ## Fail the sync if a shared resource is found By default, Argo CD will apply all manifests found in the git path configured in the Application regardless if the resources defined in the yamls are already applied by another Application. If the `FailOnSharedResource` sync option is set, Argo CD will fail the sync whenever it finds a resource in the current Application that is already applied in the cluster by another Application.