github.com/argoproj/argo-cd/v3@v3.2.1/controller/sync_test.go (about) 1 package controller 2 3 import ( 4 "strconv" 5 "testing" 6 7 "github.com/argoproj/gitops-engine/pkg/sync" 8 synccommon "github.com/argoproj/gitops-engine/pkg/sync/common" 9 "github.com/argoproj/gitops-engine/pkg/utils/kube" 10 "github.com/stretchr/testify/assert" 11 "github.com/stretchr/testify/require" 12 corev1 "k8s.io/api/core/v1" 13 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 "k8s.io/apimachinery/pkg/runtime" 16 17 "github.com/argoproj/argo-cd/v3/common" 18 "github.com/argoproj/argo-cd/v3/controller/testdata" 19 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 20 "github.com/argoproj/argo-cd/v3/reposerver/apiclient" 21 "github.com/argoproj/argo-cd/v3/test" 22 "github.com/argoproj/argo-cd/v3/util/argo/diff" 23 "github.com/argoproj/argo-cd/v3/util/argo/normalizers" 24 ) 25 26 func TestPersistRevisionHistory(t *testing.T) { 27 app := newFakeApp() 28 app.Status.OperationState = nil 29 app.Status.History = nil 30 31 defaultProject := &v1alpha1.AppProject{ 32 ObjectMeta: metav1.ObjectMeta{ 33 Namespace: test.FakeArgoCDNamespace, 34 Name: "default", 35 }, 36 } 37 data := fakeData{ 38 apps: []runtime.Object{app, defaultProject}, 39 manifestResponse: &apiclient.ManifestResponse{ 40 Manifests: []string{}, 41 Namespace: test.FakeDestNamespace, 42 Server: test.FakeClusterURL, 43 Revision: "abc123", 44 }, 45 managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), 46 } 47 ctrl := newFakeController(&data, nil) 48 49 // Sync with source unspecified 50 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 51 Sync: &v1alpha1.SyncOperation{}, 52 }} 53 ctrl.appStateManager.SyncAppState(app, defaultProject, opState) 54 // Ensure we record spec.source into sync result 55 assert.Equal(t, app.Spec.GetSource(), opState.SyncResult.Source) 56 57 updatedApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(app.Namespace).Get(t.Context(), app.Name, metav1.GetOptions{}) 58 require.NoError(t, err) 59 require.Len(t, updatedApp.Status.History, 1) 60 assert.Equal(t, app.Spec.GetSource(), updatedApp.Status.History[0].Source) 61 assert.Equal(t, "abc123", updatedApp.Status.History[0].Revision) 62 } 63 64 func TestPersistManagedNamespaceMetadataState(t *testing.T) { 65 app := newFakeApp() 66 app.Status.OperationState = nil 67 app.Status.History = nil 68 app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{ 69 Labels: map[string]string{ 70 "foo": "bar", 71 }, 72 Annotations: map[string]string{ 73 "foo": "bar", 74 }, 75 } 76 77 defaultProject := &v1alpha1.AppProject{ 78 ObjectMeta: metav1.ObjectMeta{ 79 Namespace: test.FakeArgoCDNamespace, 80 Name: "default", 81 }, 82 } 83 data := fakeData{ 84 apps: []runtime.Object{app, defaultProject}, 85 manifestResponse: &apiclient.ManifestResponse{ 86 Manifests: []string{}, 87 Namespace: test.FakeDestNamespace, 88 Server: test.FakeClusterURL, 89 Revision: "abc123", 90 }, 91 managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), 92 } 93 ctrl := newFakeController(&data, nil) 94 95 // Sync with source unspecified 96 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 97 Sync: &v1alpha1.SyncOperation{}, 98 }} 99 ctrl.appStateManager.SyncAppState(app, defaultProject, opState) 100 // Ensure we record spec.syncPolicy.managedNamespaceMetadata into sync result 101 assert.Equal(t, app.Spec.SyncPolicy.ManagedNamespaceMetadata, opState.SyncResult.ManagedNamespaceMetadata) 102 } 103 104 func TestPersistRevisionHistoryRollback(t *testing.T) { 105 app := newFakeApp() 106 app.Status.OperationState = nil 107 app.Status.History = nil 108 defaultProject := &v1alpha1.AppProject{ 109 ObjectMeta: metav1.ObjectMeta{ 110 Namespace: test.FakeArgoCDNamespace, 111 Name: "default", 112 }, 113 } 114 data := fakeData{ 115 apps: []runtime.Object{app, defaultProject}, 116 manifestResponse: &apiclient.ManifestResponse{ 117 Manifests: []string{}, 118 Namespace: test.FakeDestNamespace, 119 Server: test.FakeClusterURL, 120 Revision: "abc123", 121 }, 122 managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), 123 } 124 ctrl := newFakeController(&data, nil) 125 126 // Sync with source specified 127 source := v1alpha1.ApplicationSource{ 128 Helm: &v1alpha1.ApplicationSourceHelm{ 129 Parameters: []v1alpha1.HelmParameter{ 130 { 131 Name: "test", 132 Value: "123", 133 }, 134 }, 135 }, 136 } 137 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 138 Sync: &v1alpha1.SyncOperation{ 139 Source: &source, 140 }, 141 }} 142 ctrl.appStateManager.SyncAppState(app, defaultProject, opState) 143 // Ensure we record opState's source into sync result 144 assert.Equal(t, source, opState.SyncResult.Source) 145 146 updatedApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(app.Namespace).Get(t.Context(), app.Name, metav1.GetOptions{}) 147 require.NoError(t, err) 148 assert.Len(t, updatedApp.Status.History, 1) 149 assert.Equal(t, source, updatedApp.Status.History[0].Source) 150 assert.Equal(t, "abc123", updatedApp.Status.History[0].Revision) 151 } 152 153 func TestSyncComparisonError(t *testing.T) { 154 app := newFakeApp() 155 app.Status.OperationState = nil 156 app.Status.History = nil 157 158 defaultProject := &v1alpha1.AppProject{ 159 ObjectMeta: metav1.ObjectMeta{ 160 Namespace: test.FakeArgoCDNamespace, 161 Name: "default", 162 }, 163 Spec: v1alpha1.AppProjectSpec{ 164 SignatureKeys: []v1alpha1.SignatureKey{{KeyID: "test"}}, 165 }, 166 } 167 data := fakeData{ 168 apps: []runtime.Object{app, defaultProject}, 169 manifestResponse: &apiclient.ManifestResponse{ 170 Manifests: []string{}, 171 Namespace: test.FakeDestNamespace, 172 Server: test.FakeClusterURL, 173 Revision: "abc123", 174 VerifyResult: "something went wrong", 175 }, 176 managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), 177 } 178 ctrl := newFakeController(&data, nil) 179 180 // Sync with source unspecified 181 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 182 Sync: &v1alpha1.SyncOperation{}, 183 }} 184 t.Setenv("ARGOCD_GPG_ENABLED", "true") 185 ctrl.appStateManager.SyncAppState(app, defaultProject, opState) 186 187 conditions := app.Status.GetConditions(map[v1alpha1.ApplicationConditionType]bool{v1alpha1.ApplicationConditionComparisonError: true}) 188 assert.NotEmpty(t, conditions) 189 assert.Equal(t, "abc123", opState.SyncResult.Revision) 190 } 191 192 func TestAppStateManager_SyncAppState(t *testing.T) { 193 t.Parallel() 194 195 type fixture struct { 196 application *v1alpha1.Application 197 project *v1alpha1.AppProject 198 controller *ApplicationController 199 } 200 201 setup := func(liveObjects map[kube.ResourceKey]*unstructured.Unstructured) *fixture { 202 app := newFakeApp() 203 app.Status.OperationState = nil 204 app.Status.History = nil 205 206 if liveObjects == nil { 207 liveObjects = make(map[kube.ResourceKey]*unstructured.Unstructured) 208 } 209 210 project := &v1alpha1.AppProject{ 211 ObjectMeta: metav1.ObjectMeta{ 212 Namespace: test.FakeArgoCDNamespace, 213 Name: "default", 214 }, 215 Spec: v1alpha1.AppProjectSpec{ 216 SignatureKeys: []v1alpha1.SignatureKey{{KeyID: "test"}}, 217 Destinations: []v1alpha1.ApplicationDestination{ 218 { 219 Namespace: "*", 220 Server: "*", 221 }, 222 }, 223 }, 224 } 225 data := fakeData{ 226 apps: []runtime.Object{app, project}, 227 manifestResponse: &apiclient.ManifestResponse{ 228 Manifests: []string{}, 229 Namespace: test.FakeDestNamespace, 230 Server: test.FakeClusterURL, 231 Revision: "abc123", 232 }, 233 managedLiveObjs: liveObjects, 234 } 235 ctrl := newFakeController(&data, nil) 236 237 return &fixture{ 238 application: app, 239 project: project, 240 controller: ctrl, 241 } 242 } 243 244 t.Run("will fail the sync if finds shared resources", func(t *testing.T) { 245 // given 246 t.Parallel() 247 248 sharedObject := kube.MustToUnstructured(&corev1.ConfigMap{ 249 TypeMeta: metav1.TypeMeta{ 250 APIVersion: "v1", 251 Kind: "ConfigMap", 252 }, 253 ObjectMeta: metav1.ObjectMeta{ 254 Name: "configmap1", 255 Namespace: "default", 256 Annotations: map[string]string{ 257 common.AnnotationKeyAppInstance: "guestbook:/ConfigMap:default/configmap1", 258 }, 259 }, 260 }) 261 liveObjects := make(map[kube.ResourceKey]*unstructured.Unstructured) 262 liveObjects[kube.GetResourceKey(sharedObject)] = sharedObject 263 f := setup(liveObjects) 264 265 // Sync with source unspecified 266 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 267 Sync: &v1alpha1.SyncOperation{ 268 Source: &v1alpha1.ApplicationSource{}, 269 SyncOptions: []string{"FailOnSharedResource=true"}, 270 }, 271 }} 272 273 // when 274 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 275 276 // then 277 assert.Equal(t, synccommon.OperationFailed, opState.Phase) 278 assert.Contains(t, opState.Message, "ConfigMap/configmap1 is part of applications fake-argocd-ns/my-app and guestbook") 279 }) 280 } 281 282 func TestSyncWindowDeniesSync(t *testing.T) { 283 t.Parallel() 284 285 type fixture struct { 286 application *v1alpha1.Application 287 project *v1alpha1.AppProject 288 controller *ApplicationController 289 } 290 291 setup := func() *fixture { 292 app := newFakeApp() 293 app.Status.OperationState = nil 294 app.Status.History = nil 295 296 project := &v1alpha1.AppProject{ 297 ObjectMeta: metav1.ObjectMeta{ 298 Namespace: test.FakeArgoCDNamespace, 299 Name: "default", 300 }, 301 Spec: v1alpha1.AppProjectSpec{ 302 SyncWindows: v1alpha1.SyncWindows{{ 303 Kind: "deny", 304 Schedule: "0 0 * * *", 305 Duration: "24h", 306 Clusters: []string{"*"}, 307 Namespaces: []string{"*"}, 308 Applications: []string{"*"}, 309 }}, 310 }, 311 } 312 data := fakeData{ 313 apps: []runtime.Object{app, project}, 314 manifestResponse: &apiclient.ManifestResponse{ 315 Manifests: []string{}, 316 Namespace: test.FakeDestNamespace, 317 Server: test.FakeClusterURL, 318 Revision: "abc123", 319 }, 320 managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), 321 } 322 ctrl := newFakeController(&data, nil) 323 324 return &fixture{ 325 application: app, 326 project: project, 327 controller: ctrl, 328 } 329 } 330 331 t.Run("will keep the sync progressing if a sync window prevents the sync", func(t *testing.T) { 332 // given a project with an active deny sync window and an operation in progress 333 t.Parallel() 334 f := setup() 335 opMessage := "Sync operation blocked by sync window" 336 337 opState := &v1alpha1.OperationState{ 338 Operation: v1alpha1.Operation{ 339 Sync: &v1alpha1.SyncOperation{ 340 Source: &v1alpha1.ApplicationSource{}, 341 }, 342 }, 343 Phase: synccommon.OperationRunning, 344 } 345 // when 346 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 347 348 // then 349 assert.Equal(t, synccommon.OperationRunning, opState.Phase) 350 assert.Contains(t, opState.Message, opMessage) 351 }) 352 } 353 354 func TestNormalizeTargetResources(t *testing.T) { 355 type fixture struct { 356 comparisonResult *comparisonResult 357 } 358 setup := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *fixture { 359 t.Helper() 360 dc, err := diff.NewDiffConfigBuilder(). 361 WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}). 362 WithNoCache(). 363 Build() 364 require.NoError(t, err) 365 live := test.YamlToUnstructured(testdata.LiveDeploymentYaml) 366 target := test.YamlToUnstructured(testdata.TargetDeploymentYaml) 367 return &fixture{ 368 &comparisonResult{ 369 reconciliationResult: sync.ReconciliationResult{ 370 Live: []*unstructured.Unstructured{live}, 371 Target: []*unstructured.Unstructured{target}, 372 }, 373 diffConfig: dc, 374 }, 375 } 376 } 377 t.Run("will modify target resource adding live state in fields it should ignore", func(t *testing.T) { 378 // given 379 ignore := v1alpha1.ResourceIgnoreDifferences{ 380 Group: "*", 381 Kind: "*", 382 ManagedFieldsManagers: []string{"janitor"}, 383 } 384 ignores := []v1alpha1.ResourceIgnoreDifferences{ignore} 385 f := setup(t, ignores) 386 387 // when 388 targets, err := normalizeTargetResources(f.comparisonResult) 389 390 // then 391 require.NoError(t, err) 392 require.Len(t, targets, 1) 393 iksmVersion := targets[0].GetAnnotations()["iksm-version"] 394 assert.Equal(t, "2.0", iksmVersion) 395 }) 396 t.Run("will not modify target resource if ignore difference is not configured", func(t *testing.T) { 397 // given 398 f := setup(t, []v1alpha1.ResourceIgnoreDifferences{}) 399 400 // when 401 targets, err := normalizeTargetResources(f.comparisonResult) 402 403 // then 404 require.NoError(t, err) 405 require.Len(t, targets, 1) 406 iksmVersion := targets[0].GetAnnotations()["iksm-version"] 407 assert.Equal(t, "1.0", iksmVersion) 408 }) 409 t.Run("will remove fields from target if not present in live", func(t *testing.T) { 410 ignore := v1alpha1.ResourceIgnoreDifferences{ 411 Group: "apps", 412 Kind: "Deployment", 413 JSONPointers: []string{"/metadata/annotations/iksm-version"}, 414 } 415 ignores := []v1alpha1.ResourceIgnoreDifferences{ignore} 416 f := setup(t, ignores) 417 live := f.comparisonResult.reconciliationResult.Live[0] 418 unstructured.RemoveNestedField(live.Object, "metadata", "annotations", "iksm-version") 419 420 // when 421 targets, err := normalizeTargetResources(f.comparisonResult) 422 423 // then 424 require.NoError(t, err) 425 require.Len(t, targets, 1) 426 _, ok := targets[0].GetAnnotations()["iksm-version"] 427 assert.False(t, ok) 428 }) 429 t.Run("will correctly normalize with multiple ignore configurations", func(t *testing.T) { 430 // given 431 ignores := []v1alpha1.ResourceIgnoreDifferences{ 432 { 433 Group: "apps", 434 Kind: "Deployment", 435 JSONPointers: []string{"/spec/replicas"}, 436 }, 437 { 438 Group: "*", 439 Kind: "*", 440 ManagedFieldsManagers: []string{"janitor"}, 441 }, 442 } 443 f := setup(t, ignores) 444 445 // when 446 targets, err := normalizeTargetResources(f.comparisonResult) 447 448 // then 449 require.NoError(t, err) 450 require.Len(t, targets, 1) 451 normalized := targets[0] 452 iksmVersion, ok := normalized.GetAnnotations()["iksm-version"] 453 require.True(t, ok) 454 assert.Equal(t, "2.0", iksmVersion) 455 replicas, ok, err := unstructured.NestedInt64(normalized.Object, "spec", "replicas") 456 require.NoError(t, err) 457 require.True(t, ok) 458 assert.Equal(t, int64(4), replicas) 459 }) 460 t.Run("will keep new array entries not found in live state if not ignored", func(t *testing.T) { 461 t.Skip("limitation in the current implementation") 462 // given 463 ignores := []v1alpha1.ResourceIgnoreDifferences{ 464 { 465 Group: "apps", 466 Kind: "Deployment", 467 JQPathExpressions: []string{".spec.template.spec.containers[] | select(.name == \"guestbook-ui\")"}, 468 }, 469 } 470 f := setup(t, ignores) 471 target := test.YamlToUnstructured(testdata.TargetDeploymentNewEntries) 472 f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target} 473 474 // when 475 targets, err := normalizeTargetResources(f.comparisonResult) 476 477 // then 478 require.NoError(t, err) 479 require.Len(t, targets, 1) 480 containers, ok, err := unstructured.NestedSlice(targets[0].Object, "spec", "template", "spec", "containers") 481 require.NoError(t, err) 482 require.True(t, ok) 483 assert.Len(t, containers, 2) 484 }) 485 } 486 487 func TestNormalizeTargetResourcesWithList(t *testing.T) { 488 type fixture struct { 489 comparisonResult *comparisonResult 490 } 491 setupHTTPProxy := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *fixture { 492 t.Helper() 493 dc, err := diff.NewDiffConfigBuilder(). 494 WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}). 495 WithNoCache(). 496 Build() 497 require.NoError(t, err) 498 live := test.YamlToUnstructured(testdata.LiveHTTPProxy) 499 target := test.YamlToUnstructured(testdata.TargetHTTPProxy) 500 return &fixture{ 501 &comparisonResult{ 502 reconciliationResult: sync.ReconciliationResult{ 503 Live: []*unstructured.Unstructured{live}, 504 Target: []*unstructured.Unstructured{target}, 505 }, 506 diffConfig: dc, 507 }, 508 } 509 } 510 511 t.Run("will properly ignore nested fields within arrays", func(t *testing.T) { 512 // given 513 ignores := []v1alpha1.ResourceIgnoreDifferences{ 514 { 515 Group: "projectcontour.io", 516 Kind: "HTTPProxy", 517 JQPathExpressions: []string{".spec.routes[]"}, 518 // JSONPointers: []string{"/spec/routes"}, 519 }, 520 } 521 f := setupHTTPProxy(t, ignores) 522 target := test.YamlToUnstructured(testdata.TargetHTTPProxy) 523 f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target} 524 525 // when 526 patchedTargets, err := normalizeTargetResources(f.comparisonResult) 527 528 // then 529 require.NoError(t, err) 530 require.Len(t, f.comparisonResult.reconciliationResult.Live, 1) 531 require.Len(t, f.comparisonResult.reconciliationResult.Target, 1) 532 require.Len(t, patchedTargets, 1) 533 534 // live should have 1 entry 535 require.Len(t, dig(f.comparisonResult.reconciliationResult.Live[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors"), 1) 536 // assert some arbitrary field to show `entries[0]` is not an empty object 537 require.Equal(t, "sample-header", dig(f.comparisonResult.reconciliationResult.Live[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries", 0, "requestHeader", "headerName")) 538 539 // target has 2 entries 540 require.Len(t, dig(f.comparisonResult.reconciliationResult.Target[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries"), 2) 541 // assert some arbitrary field to show `entries[0]` is not an empty object 542 require.Equal(t, "sample-header", dig(f.comparisonResult.reconciliationResult.Target[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries", 0, "requestHeaderValueMatch", "headers", 0, "name")) 543 544 // It should be *1* entries in the array 545 require.Len(t, dig(patchedTargets[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors"), 1) 546 // and it should NOT equal an empty object 547 require.Len(t, dig(patchedTargets[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries", 0), 1) 548 }) 549 t.Run("will correctly set array entries if new entries have been added", func(t *testing.T) { 550 // given 551 ignores := []v1alpha1.ResourceIgnoreDifferences{ 552 { 553 Group: "apps", 554 Kind: "Deployment", 555 JQPathExpressions: []string{".spec.template.spec.containers[].env[] | select(.name == \"SOME_ENV_VAR\")"}, 556 }, 557 } 558 f := setupHTTPProxy(t, ignores) 559 live := test.YamlToUnstructured(testdata.LiveDeploymentEnvVarsYaml) 560 target := test.YamlToUnstructured(testdata.TargetDeploymentEnvVarsYaml) 561 f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{live} 562 f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target} 563 564 // when 565 targets, err := normalizeTargetResources(f.comparisonResult) 566 567 // then 568 require.NoError(t, err) 569 require.Len(t, targets, 1) 570 containers, ok, err := unstructured.NestedSlice(targets[0].Object, "spec", "template", "spec", "containers") 571 require.NoError(t, err) 572 require.True(t, ok) 573 assert.Len(t, containers, 1) 574 575 ports := containers[0].(map[string]any)["ports"].([]any) 576 assert.Len(t, ports, 1) 577 578 env := containers[0].(map[string]any)["env"].([]any) 579 assert.Len(t, env, 3) 580 581 first := env[0] 582 second := env[1] 583 third := env[2] 584 585 // Currently the defined order at this time is the insertion order of the target manifest. 586 assert.Equal(t, "SOME_ENV_VAR", first.(map[string]any)["name"]) 587 assert.Equal(t, "some_value", first.(map[string]any)["value"]) 588 589 assert.Equal(t, "SOME_OTHER_ENV_VAR", second.(map[string]any)["name"]) 590 assert.Equal(t, "some_other_value", second.(map[string]any)["value"]) 591 592 assert.Equal(t, "YET_ANOTHER_ENV_VAR", third.(map[string]any)["name"]) 593 assert.Equal(t, "yet_another_value", third.(map[string]any)["value"]) 594 }) 595 596 t.Run("ignore-deployment-image-replicas-changes-additive", func(t *testing.T) { 597 // given 598 599 ignores := []v1alpha1.ResourceIgnoreDifferences{ 600 { 601 Group: "apps", 602 Kind: "Deployment", 603 JSONPointers: []string{"/spec/replicas"}, 604 }, { 605 Group: "apps", 606 Kind: "Deployment", 607 JQPathExpressions: []string{".spec.template.spec.containers[].image"}, 608 }, 609 } 610 f := setupHTTPProxy(t, ignores) 611 live := test.YamlToUnstructured(testdata.MinimalImageReplicaDeploymentYaml) 612 target := test.YamlToUnstructured(testdata.AdditionalImageReplicaDeploymentYaml) 613 f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{live} 614 f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target} 615 616 // when 617 targets, err := normalizeTargetResources(f.comparisonResult) 618 619 // then 620 require.NoError(t, err) 621 require.Len(t, targets, 1) 622 metadata, ok, err := unstructured.NestedMap(targets[0].Object, "metadata") 623 require.NoError(t, err) 624 require.True(t, ok) 625 labels, ok := metadata["labels"].(map[string]any) 626 require.True(t, ok) 627 assert.Len(t, labels, 2) 628 assert.Equal(t, "web", labels["appProcess"]) 629 630 spec, ok, err := unstructured.NestedMap(targets[0].Object, "spec") 631 require.NoError(t, err) 632 require.True(t, ok) 633 634 assert.Equal(t, int64(1), spec["replicas"]) 635 636 template, ok := spec["template"].(map[string]any) 637 require.True(t, ok) 638 639 tMetadata, ok := template["metadata"].(map[string]any) 640 require.True(t, ok) 641 tLabels, ok := tMetadata["labels"].(map[string]any) 642 require.True(t, ok) 643 assert.Len(t, tLabels, 2) 644 assert.Equal(t, "web", tLabels["appProcess"]) 645 646 tSpec, ok := template["spec"].(map[string]any) 647 require.True(t, ok) 648 containers, ok, err := unstructured.NestedSlice(tSpec, "containers") 649 require.NoError(t, err) 650 require.True(t, ok) 651 assert.Len(t, containers, 1) 652 653 first := containers[0].(map[string]any) 654 assert.Equal(t, "alpine:3", first["image"]) 655 656 resources, ok := first["resources"].(map[string]any) 657 require.True(t, ok) 658 requests, ok := resources["requests"].(map[string]any) 659 require.True(t, ok) 660 assert.Equal(t, "400m", requests["cpu"]) 661 662 env, ok, err := unstructured.NestedSlice(first, "env") 663 require.NoError(t, err) 664 require.True(t, ok) 665 assert.Len(t, env, 1) 666 667 env0 := env[0].(map[string]any) 668 assert.Equal(t, "EV", env0["name"]) 669 assert.Equal(t, "here", env0["value"]) 670 }) 671 } 672 673 func TestDeriveServiceAccountMatchingNamespaces(t *testing.T) { 674 t.Parallel() 675 676 type fixture struct { 677 project *v1alpha1.AppProject 678 application *v1alpha1.Application 679 cluster *v1alpha1.Cluster 680 } 681 682 setup := func(destinationServiceAccounts []v1alpha1.ApplicationDestinationServiceAccount, destinationNamespace, destinationServerURL, applicationNamespace string) *fixture { 683 project := &v1alpha1.AppProject{ 684 ObjectMeta: metav1.ObjectMeta{ 685 Namespace: "argocd-ns", 686 Name: "testProj", 687 }, 688 Spec: v1alpha1.AppProjectSpec{ 689 DestinationServiceAccounts: destinationServiceAccounts, 690 }, 691 } 692 app := &v1alpha1.Application{ 693 ObjectMeta: metav1.ObjectMeta{ 694 Namespace: applicationNamespace, 695 Name: "testApp", 696 }, 697 Spec: v1alpha1.ApplicationSpec{ 698 Project: "testProj", 699 Destination: v1alpha1.ApplicationDestination{ 700 Server: destinationServerURL, 701 Namespace: destinationNamespace, 702 }, 703 }, 704 } 705 cluster := &v1alpha1.Cluster{ 706 Server: "https://kubernetes.svc.local", 707 Name: "test-cluster", 708 } 709 return &fixture{ 710 project: project, 711 application: app, 712 cluster: cluster, 713 } 714 } 715 716 t.Run("empty destination service accounts", func(t *testing.T) { 717 // given an application referring a project with no destination service accounts 718 t.Parallel() 719 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{} 720 destinationNamespace := "testns" 721 destinationServerURL := "https://kubernetes.svc.local" 722 applicationNamespace := "argocd-ns" 723 expectedSA := "" 724 expectedErrMsg := "no matching service account found for destination server https://kubernetes.svc.local and namespace testns" 725 726 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 727 // when 728 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 729 assert.Equal(t, expectedSA, sa) 730 731 // then, there should be an error saying no valid match was found 732 assert.EqualError(t, err, expectedErrMsg) 733 }) 734 735 t.Run("exact match of destination namespace", func(t *testing.T) { 736 // given an application referring a project with exactly one destination service account that matches the application destination, 737 t.Parallel() 738 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 739 { 740 Server: "https://kubernetes.svc.local", 741 Namespace: "testns", 742 DefaultServiceAccount: "test-sa", 743 }, 744 } 745 destinationNamespace := "testns" 746 destinationServerURL := "https://kubernetes.svc.local" 747 applicationNamespace := "argocd-ns" 748 expectedSA := "system:serviceaccount:testns:test-sa" 749 750 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 751 // when 752 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 753 754 // then, there should be no error and should use the right service account for impersonation 755 require.NoError(t, err) 756 assert.Equal(t, expectedSA, sa) 757 }) 758 759 t.Run("exact one match with multiple destination service accounts", func(t *testing.T) { 760 // given an application referring a project with multiple destination service accounts having one exact match for application destination 761 t.Parallel() 762 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 763 { 764 Server: "https://kubernetes.svc.local", 765 Namespace: "guestbook", 766 DefaultServiceAccount: "guestbook-sa", 767 }, 768 { 769 Server: "https://kubernetes.svc.local", 770 Namespace: "guestbook-test", 771 DefaultServiceAccount: "guestbook-test-sa", 772 }, 773 { 774 Server: "https://kubernetes.svc.local", 775 Namespace: "default", 776 DefaultServiceAccount: "default-sa", 777 }, 778 { 779 Server: "https://kubernetes.svc.local", 780 Namespace: "testns", 781 DefaultServiceAccount: "test-sa", 782 }, 783 } 784 destinationNamespace := "testns" 785 destinationServerURL := "https://kubernetes.svc.local" 786 applicationNamespace := "argocd-ns" 787 expectedSA := "system:serviceaccount:testns:test-sa" 788 789 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 790 // when 791 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 792 793 // then, there should be no error and should use the right service account for impersonation 794 require.NoError(t, err) 795 assert.Equal(t, expectedSA, sa) 796 }) 797 798 t.Run("first match to be used when multiple matches are available", func(t *testing.T) { 799 // given an application referring a project with multiple destination service accounts having multiple match for application destination 800 t.Parallel() 801 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 802 { 803 Server: "https://kubernetes.svc.local", 804 Namespace: "testns", 805 DefaultServiceAccount: "test-sa", 806 }, 807 { 808 Server: "https://kubernetes.svc.local", 809 Namespace: "testns", 810 DefaultServiceAccount: "test-sa-2", 811 }, 812 { 813 Server: "https://kubernetes.svc.local", 814 Namespace: "testns", 815 DefaultServiceAccount: "test-sa-3", 816 }, 817 { 818 Server: "https://kubernetes.svc.local", 819 Namespace: "guestbook", 820 DefaultServiceAccount: "guestbook-sa", 821 }, 822 } 823 destinationNamespace := "testns" 824 destinationServerURL := "https://kubernetes.svc.local" 825 applicationNamespace := "argocd-ns" 826 expectedSA := "system:serviceaccount:testns:test-sa" 827 828 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 829 // when 830 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 831 832 // then, there should be no error and it should use the first matching service account for impersonation 833 require.NoError(t, err) 834 assert.Equal(t, expectedSA, sa) 835 }) 836 837 t.Run("first match to be used when glob pattern is used", func(t *testing.T) { 838 // given an application referring a project with multiple destination service accounts with glob patterns matching the application destination 839 t.Parallel() 840 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 841 { 842 Server: "https://kubernetes.svc.local", 843 Namespace: "test*", 844 DefaultServiceAccount: "test-sa", 845 }, 846 { 847 Server: "https://kubernetes.svc.local", 848 Namespace: "testns", 849 DefaultServiceAccount: "test-sa-2", 850 }, 851 { 852 Server: "https://kubernetes.svc.local", 853 Namespace: "default", 854 DefaultServiceAccount: "default-sa", 855 }, 856 } 857 destinationNamespace := "testns" 858 destinationServerURL := "https://kubernetes.svc.local" 859 applicationNamespace := "argocd-ns" 860 expectedSA := "system:serviceaccount:testns:test-sa" 861 862 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 863 // when 864 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 865 866 // then, there should not be any error and should use the first matching glob pattern service account for impersonation 867 require.NoError(t, err) 868 assert.Equal(t, expectedSA, sa) 869 }) 870 871 t.Run("no match among a valid list", func(t *testing.T) { 872 // given an application referring a project with multiple destination service accounts with no matches for application destination 873 t.Parallel() 874 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 875 { 876 Server: "https://kubernetes.svc.local", 877 Namespace: "test1", 878 DefaultServiceAccount: "test-sa", 879 }, 880 { 881 Server: "https://kubernetes.svc.local", 882 Namespace: "test2", 883 DefaultServiceAccount: "test-sa-2", 884 }, 885 { 886 Server: "https://kubernetes.svc.local", 887 Namespace: "default", 888 DefaultServiceAccount: "default-sa", 889 }, 890 } 891 destinationNamespace := "testns" 892 destinationServerURL := "https://kubernetes.svc.local" 893 applicationNamespace := "argocd-ns" 894 expectedSA := "" 895 expectedErrMsg := "no matching service account found for destination server https://kubernetes.svc.local and namespace testns" 896 897 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 898 // when 899 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 900 901 // then, there should be an error saying no match was found 902 require.EqualError(t, err, expectedErrMsg) 903 assert.Equal(t, expectedSA, sa) 904 }) 905 906 t.Run("app destination namespace is empty", func(t *testing.T) { 907 // given an application referring a project with multiple destination service accounts with empty application destination namespace 908 t.Parallel() 909 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 910 { 911 Server: "https://kubernetes.svc.local", 912 DefaultServiceAccount: "test-sa", 913 }, 914 { 915 Server: "https://kubernetes.svc.local", 916 Namespace: "*", 917 DefaultServiceAccount: "test-sa-2", 918 }, 919 } 920 destinationNamespace := "" 921 destinationServerURL := "https://kubernetes.svc.local" 922 applicationNamespace := "argocd-ns" 923 expectedSA := "system:serviceaccount:argocd-ns:test-sa" 924 925 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 926 // when 927 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 928 929 // then, there should not be any error and the service account configured for with empty namespace should be used. 930 require.NoError(t, err) 931 assert.Equal(t, expectedSA, sa) 932 }) 933 934 t.Run("match done via catch all glob pattern", func(t *testing.T) { 935 // given an application referring a project with multiple destination service accounts having a catch all glob pattern 936 t.Parallel() 937 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 938 { 939 Server: "https://kubernetes.svc.local", 940 Namespace: "testns1", 941 DefaultServiceAccount: "test-sa-2", 942 }, 943 { 944 Server: "https://kubernetes.svc.local", 945 Namespace: "default", 946 DefaultServiceAccount: "default-sa", 947 }, 948 { 949 Server: "https://kubernetes.svc.local", 950 Namespace: "*", 951 DefaultServiceAccount: "test-sa", 952 }, 953 } 954 destinationNamespace := "testns" 955 destinationServerURL := "https://kubernetes.svc.local" 956 applicationNamespace := "argocd-ns" 957 expectedSA := "system:serviceaccount:testns:test-sa" 958 959 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 960 // when 961 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 962 963 // then, there should not be any error and the catch all service account should be returned 964 require.NoError(t, err) 965 assert.Equal(t, expectedSA, sa) 966 }) 967 968 t.Run("match done via invalid glob pattern", func(t *testing.T) { 969 // given an application referring a project with a destination service account having an invalid glob pattern for namespace 970 t.Parallel() 971 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 972 { 973 Server: "https://kubernetes.svc.local", 974 Namespace: "e[[a*", 975 DefaultServiceAccount: "test-sa", 976 }, 977 } 978 destinationNamespace := "testns" 979 destinationServerURL := "https://kubernetes.svc.local" 980 applicationNamespace := "argocd-ns" 981 expectedSA := "" 982 983 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 984 // when 985 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 986 987 // then, there must be an error as the glob pattern is invalid. 988 require.ErrorContains(t, err, "invalid glob pattern for destination namespace") 989 assert.Equal(t, expectedSA, sa) 990 }) 991 992 t.Run("sa specified with a namespace", func(t *testing.T) { 993 // given an application referring a project with multiple destination service accounts having a matching service account specified with its namespace 994 t.Parallel() 995 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 996 { 997 Server: "https://kubernetes.svc.local", 998 Namespace: "testns", 999 DefaultServiceAccount: "myns:test-sa", 1000 }, 1001 { 1002 Server: "https://kubernetes.svc.local", 1003 Namespace: "default", 1004 DefaultServiceAccount: "default-sa", 1005 }, 1006 { 1007 Server: "https://kubernetes.svc.local", 1008 Namespace: "*", 1009 DefaultServiceAccount: "test-sa", 1010 }, 1011 } 1012 destinationNamespace := "testns" 1013 destinationServerURL := "https://kubernetes.svc.local" 1014 applicationNamespace := "argocd-ns" 1015 expectedSA := "system:serviceaccount:myns:test-sa" 1016 1017 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1018 // when 1019 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1020 assert.Equal(t, expectedSA, sa) 1021 1022 // then, there should not be any error and the service account with its namespace should be returned. 1023 require.NoError(t, err) 1024 }) 1025 1026 t.Run("app destination name instead of server URL", func(t *testing.T) { 1027 t.Parallel() 1028 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1029 { 1030 Server: "https://kubernetes.svc.local", 1031 Namespace: "*", 1032 DefaultServiceAccount: "test-sa", 1033 }, 1034 } 1035 destinationNamespace := "testns" 1036 destinationServerURL := "https://kubernetes.svc.local" 1037 applicationNamespace := "argocd-ns" 1038 expectedSA := "system:serviceaccount:testns:test-sa" 1039 1040 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1041 1042 // Use destination name instead of server URL 1043 f.application.Spec.Destination.Server = "" 1044 f.application.Spec.Destination.Name = f.cluster.Name 1045 1046 // when 1047 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1048 assert.Equal(t, expectedSA, sa) 1049 1050 // then, there should not be any error and the service account with its namespace should be returned. 1051 require.NoError(t, err) 1052 }) 1053 } 1054 1055 func TestDeriveServiceAccountMatchingServers(t *testing.T) { 1056 t.Parallel() 1057 1058 type fixture struct { 1059 project *v1alpha1.AppProject 1060 application *v1alpha1.Application 1061 cluster *v1alpha1.Cluster 1062 } 1063 1064 setup := func(destinationServiceAccounts []v1alpha1.ApplicationDestinationServiceAccount, destinationNamespace, destinationServerURL, applicationNamespace string) *fixture { 1065 project := &v1alpha1.AppProject{ 1066 ObjectMeta: metav1.ObjectMeta{ 1067 Namespace: "argocd-ns", 1068 Name: "testProj", 1069 }, 1070 Spec: v1alpha1.AppProjectSpec{ 1071 DestinationServiceAccounts: destinationServiceAccounts, 1072 }, 1073 } 1074 app := &v1alpha1.Application{ 1075 ObjectMeta: metav1.ObjectMeta{ 1076 Namespace: applicationNamespace, 1077 Name: "testApp", 1078 }, 1079 Spec: v1alpha1.ApplicationSpec{ 1080 Project: "testProj", 1081 Destination: v1alpha1.ApplicationDestination{ 1082 Server: destinationServerURL, 1083 Namespace: destinationNamespace, 1084 }, 1085 }, 1086 } 1087 cluster := &v1alpha1.Cluster{ 1088 Server: "https://kubernetes.svc.local", 1089 Name: "test-cluster", 1090 } 1091 return &fixture{ 1092 project: project, 1093 application: app, 1094 cluster: cluster, 1095 } 1096 } 1097 1098 t.Run("exact one match with multiple destination service accounts", func(t *testing.T) { 1099 // given an application referring a project with multiple destination service accounts and one exact match for application destination 1100 t.Parallel() 1101 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1102 { 1103 Server: "https://kubernetes.svc.local", 1104 Namespace: "guestbook", 1105 DefaultServiceAccount: "guestbook-sa", 1106 }, 1107 { 1108 Server: "https://abc.svc.local", 1109 Namespace: "guestbook", 1110 DefaultServiceAccount: "guestbook-test-sa", 1111 }, 1112 { 1113 Server: "https://cde.svc.local", 1114 Namespace: "guestbook", 1115 DefaultServiceAccount: "default-sa", 1116 }, 1117 { 1118 Server: "https://kubernetes.svc.local", 1119 Namespace: "testns", 1120 DefaultServiceAccount: "test-sa", 1121 }, 1122 } 1123 destinationNamespace := "testns" 1124 destinationServerURL := "https://kubernetes.svc.local" 1125 applicationNamespace := "argocd-ns" 1126 expectedSA := "system:serviceaccount:testns:test-sa" 1127 1128 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1129 // when 1130 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1131 1132 // then, there should not be any error and the right service account must be returned. 1133 require.NoError(t, err) 1134 assert.Equal(t, expectedSA, sa) 1135 }) 1136 1137 t.Run("first match to be used when multiple matches are available", func(t *testing.T) { 1138 // given an application referring a project with multiple destination service accounts and multiple matches for application destination 1139 t.Parallel() 1140 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1141 { 1142 Server: "https://kubernetes.svc.local", 1143 Namespace: "testns", 1144 DefaultServiceAccount: "test-sa", 1145 }, 1146 { 1147 Server: "https://kubernetes.svc.local", 1148 Namespace: "testns", 1149 DefaultServiceAccount: "test-sa-2", 1150 }, 1151 { 1152 Server: "https://kubernetes.svc.local", 1153 Namespace: "default", 1154 DefaultServiceAccount: "default-sa", 1155 }, 1156 { 1157 Server: "https://kubernetes.svc.local", 1158 Namespace: "guestbook", 1159 DefaultServiceAccount: "guestbook-sa", 1160 }, 1161 } 1162 destinationNamespace := "testns" 1163 destinationServerURL := "https://kubernetes.svc.local" 1164 applicationNamespace := "argocd-ns" 1165 expectedSA := "system:serviceaccount:testns:test-sa" 1166 1167 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1168 // when 1169 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1170 1171 // then, there should not be any error and first matching service account should be used 1172 require.NoError(t, err) 1173 assert.Equal(t, expectedSA, sa) 1174 }) 1175 1176 t.Run("first match to be used when glob pattern is used", func(t *testing.T) { 1177 // given an application referring a project with multiple destination service accounts with a matching glob pattern and exact match 1178 t.Parallel() 1179 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1180 { 1181 Server: "https://kubernetes.svc.local", 1182 Namespace: "test*", 1183 DefaultServiceAccount: "test-sa", 1184 }, 1185 { 1186 Server: "https://kubernetes.svc.local", 1187 Namespace: "testns", 1188 DefaultServiceAccount: "test-sa-2", 1189 }, 1190 { 1191 Server: "https://kubernetes.svc.local", 1192 Namespace: "default", 1193 DefaultServiceAccount: "default-sa", 1194 }, 1195 } 1196 destinationNamespace := "testns" 1197 destinationServerURL := "https://kubernetes.svc.local" 1198 applicationNamespace := "argocd-ns" 1199 expectedSA := "system:serviceaccount:testns:test-sa" 1200 1201 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1202 // when 1203 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1204 assert.Equal(t, expectedSA, sa) 1205 1206 // then, there should not be any error and the service account of the glob pattern, being the first match should be returned. 1207 require.NoError(t, err) 1208 }) 1209 1210 t.Run("no match among a valid list", func(t *testing.T) { 1211 // given an application referring a project with multiple destination service accounts with no match 1212 t.Parallel() 1213 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1214 { 1215 Server: "https://kubernetes.svc.local", 1216 Namespace: "testns", 1217 DefaultServiceAccount: "test-sa", 1218 }, 1219 { 1220 Server: "https://abc.svc.local", 1221 Namespace: "testns", 1222 DefaultServiceAccount: "test-sa-2", 1223 }, 1224 { 1225 Server: "https://cde.svc.local", 1226 Namespace: "default", 1227 DefaultServiceAccount: "default-sa", 1228 }, 1229 } 1230 destinationNamespace := "testns" 1231 destinationServerURL := "https://xyz.svc.local" 1232 applicationNamespace := "argocd-ns" 1233 expectedSA := "" 1234 expectedErr := "no matching service account found for destination server https://xyz.svc.local and namespace testns" 1235 1236 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1237 // when 1238 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, &v1alpha1.Cluster{Server: destinationServerURL}) 1239 1240 // then, there an error with appropriate message must be returned 1241 require.EqualError(t, err, expectedErr) 1242 assert.Equal(t, expectedSA, sa) 1243 }) 1244 1245 t.Run("match done via catch all glob pattern", func(t *testing.T) { 1246 // given an application referring a project with multiple destination service accounts with matching catch all glob pattern 1247 t.Parallel() 1248 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1249 { 1250 Server: "https://kubernetes.svc.local", 1251 Namespace: "testns1", 1252 DefaultServiceAccount: "test-sa-2", 1253 }, 1254 { 1255 Server: "https://kubernetes.svc.local", 1256 Namespace: "default", 1257 DefaultServiceAccount: "default-sa", 1258 }, 1259 { 1260 Server: "*", 1261 Namespace: "*", 1262 DefaultServiceAccount: "test-sa", 1263 }, 1264 } 1265 destinationNamespace := "testns" 1266 destinationServerURL := "https://localhost:6443" 1267 applicationNamespace := "argocd-ns" 1268 expectedSA := "system:serviceaccount:testns:test-sa" 1269 1270 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1271 // when 1272 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1273 1274 // then, there should not be any error and the service account of the glob pattern match must be returned. 1275 require.NoError(t, err) 1276 assert.Equal(t, expectedSA, sa) 1277 }) 1278 1279 t.Run("match done via invalid glob pattern", func(t *testing.T) { 1280 // given an application referring a project with a destination service account having an invalid glob pattern for server 1281 t.Parallel() 1282 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1283 { 1284 Server: "e[[a*", 1285 Namespace: "test-ns", 1286 DefaultServiceAccount: "test-sa", 1287 }, 1288 } 1289 destinationNamespace := "testns" 1290 destinationServerURL := "https://kubernetes.svc.local" 1291 applicationNamespace := "argocd-ns" 1292 expectedSA := "" 1293 1294 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1295 // when 1296 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1297 1298 // then, there must be an error as the glob pattern is invalid. 1299 require.ErrorContains(t, err, "invalid glob pattern for destination server") 1300 assert.Equal(t, expectedSA, sa) 1301 }) 1302 1303 t.Run("sa specified with a namespace", func(t *testing.T) { 1304 // given app sync impersonation feature is enabled and matching service account is prefixed with a namespace 1305 t.Parallel() 1306 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1307 { 1308 Server: "https://abc.svc.local", 1309 Namespace: "testns", 1310 DefaultServiceAccount: "myns:test-sa", 1311 }, 1312 { 1313 Server: "https://kubernetes.svc.local", 1314 Namespace: "default", 1315 DefaultServiceAccount: "default-sa", 1316 }, 1317 { 1318 Server: "*", 1319 Namespace: "*", 1320 DefaultServiceAccount: "test-sa", 1321 }, 1322 } 1323 destinationNamespace := "testns" 1324 destinationServerURL := "https://abc.svc.local" 1325 applicationNamespace := "argocd-ns" 1326 expectedSA := "system:serviceaccount:myns:test-sa" 1327 1328 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1329 // when 1330 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, &v1alpha1.Cluster{Server: destinationServerURL}) 1331 1332 // then, there should not be any error and the service account with the given namespace prefix must be returned. 1333 require.NoError(t, err) 1334 assert.Equal(t, expectedSA, sa) 1335 }) 1336 1337 t.Run("app destination name instead of server URL", func(t *testing.T) { 1338 t.Parallel() 1339 destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{ 1340 { 1341 Server: "https://kubernetes.svc.local", 1342 Namespace: "*", 1343 DefaultServiceAccount: "test-sa", 1344 }, 1345 } 1346 destinationNamespace := "testns" 1347 destinationServerURL := "https://kubernetes.svc.local" 1348 applicationNamespace := "argocd-ns" 1349 expectedSA := "system:serviceaccount:testns:test-sa" 1350 1351 f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace) 1352 1353 // Use destination name instead of server URL 1354 f.application.Spec.Destination.Server = "" 1355 f.application.Spec.Destination.Name = f.cluster.Name 1356 1357 // when 1358 sa, err := deriveServiceAccountToImpersonate(f.project, f.application, f.cluster) 1359 assert.Equal(t, expectedSA, sa) 1360 1361 // then, there should not be any error and the service account with its namespace should be returned. 1362 require.NoError(t, err) 1363 }) 1364 } 1365 1366 func TestSyncWithImpersonate(t *testing.T) { 1367 type fixture struct { 1368 application *v1alpha1.Application 1369 project *v1alpha1.AppProject 1370 controller *ApplicationController 1371 } 1372 1373 setup := func(impersonationEnabled bool, destinationNamespace, serviceAccountName string) *fixture { 1374 app := newFakeApp() 1375 app.Status.OperationState = nil 1376 app.Status.History = nil 1377 project := &v1alpha1.AppProject{ 1378 ObjectMeta: metav1.ObjectMeta{ 1379 Namespace: test.FakeArgoCDNamespace, 1380 Name: "default", 1381 }, 1382 Spec: v1alpha1.AppProjectSpec{ 1383 DestinationServiceAccounts: []v1alpha1.ApplicationDestinationServiceAccount{ 1384 { 1385 Server: "https://localhost:6443", 1386 Namespace: destinationNamespace, 1387 DefaultServiceAccount: serviceAccountName, 1388 }, 1389 }, 1390 }, 1391 } 1392 additionalObjs := []runtime.Object{} 1393 if serviceAccountName != "" { 1394 syncServiceAccount := &corev1.ServiceAccount{ 1395 ObjectMeta: metav1.ObjectMeta{ 1396 Name: serviceAccountName, 1397 Namespace: test.FakeDestNamespace, 1398 }, 1399 } 1400 additionalObjs = append(additionalObjs, syncServiceAccount) 1401 } 1402 data := fakeData{ 1403 apps: []runtime.Object{app, project}, 1404 manifestResponse: &apiclient.ManifestResponse{ 1405 Manifests: []string{}, 1406 Namespace: test.FakeDestNamespace, 1407 Server: "https://localhost:6443", 1408 Revision: "abc123", 1409 }, 1410 managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{}, 1411 configMapData: map[string]string{ 1412 "application.sync.impersonation.enabled": strconv.FormatBool(impersonationEnabled), 1413 }, 1414 additionalObjs: additionalObjs, 1415 } 1416 ctrl := newFakeController(&data, nil) 1417 return &fixture{ 1418 application: app, 1419 project: project, 1420 controller: ctrl, 1421 } 1422 } 1423 1424 t.Run("sync with impersonation and no matching service account", func(t *testing.T) { 1425 // given app sync impersonation feature is enabled with an application referring a project no matching service account 1426 f := setup(true, test.FakeArgoCDNamespace, "") 1427 opMessage := "failed to find a matching service account to impersonate: no matching service account found for destination server https://localhost:6443 and namespace fake-dest-ns" 1428 1429 opState := &v1alpha1.OperationState{ 1430 Operation: v1alpha1.Operation{ 1431 Sync: &v1alpha1.SyncOperation{ 1432 Source: &v1alpha1.ApplicationSource{}, 1433 }, 1434 }, 1435 Phase: synccommon.OperationRunning, 1436 } 1437 // when 1438 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1439 1440 // then, app sync should fail with expected error message in operation state 1441 assert.Equal(t, synccommon.OperationError, opState.Phase) 1442 assert.Contains(t, opState.Message, opMessage) 1443 }) 1444 1445 t.Run("sync with impersonation and empty service account match", func(t *testing.T) { 1446 // given app sync impersonation feature is enabled with an application referring a project matching service account that is an empty string 1447 f := setup(true, test.FakeDestNamespace, "") 1448 opMessage := "failed to find a matching service account to impersonate: default service account contains invalid chars ''" 1449 1450 opState := &v1alpha1.OperationState{ 1451 Operation: v1alpha1.Operation{ 1452 Sync: &v1alpha1.SyncOperation{ 1453 Source: &v1alpha1.ApplicationSource{}, 1454 }, 1455 }, 1456 Phase: synccommon.OperationRunning, 1457 } 1458 // when 1459 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1460 1461 // then app sync should fail with expected error message in operation state 1462 assert.Equal(t, synccommon.OperationError, opState.Phase) 1463 assert.Contains(t, opState.Message, opMessage) 1464 }) 1465 1466 t.Run("sync with impersonation and matching sa", func(t *testing.T) { 1467 // given app sync impersonation feature is enabled with an application referring a project matching service account 1468 f := setup(true, test.FakeDestNamespace, "test-sa") 1469 opMessage := "successfully synced (no more tasks)" 1470 1471 opState := &v1alpha1.OperationState{ 1472 Operation: v1alpha1.Operation{ 1473 Sync: &v1alpha1.SyncOperation{ 1474 Source: &v1alpha1.ApplicationSource{}, 1475 }, 1476 }, 1477 Phase: synccommon.OperationRunning, 1478 } 1479 // when 1480 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1481 1482 // then app sync should not fail 1483 assert.Equal(t, synccommon.OperationSucceeded, opState.Phase) 1484 assert.Contains(t, opState.Message, opMessage) 1485 }) 1486 1487 t.Run("sync without impersonation", func(t *testing.T) { 1488 // given app sync impersonation feature is disabled with an application referring a project matching service account 1489 f := setup(false, test.FakeDestNamespace, "") 1490 opMessage := "successfully synced (no more tasks)" 1491 1492 opState := &v1alpha1.OperationState{ 1493 Operation: v1alpha1.Operation{ 1494 Sync: &v1alpha1.SyncOperation{ 1495 Source: &v1alpha1.ApplicationSource{}, 1496 }, 1497 }, 1498 Phase: synccommon.OperationRunning, 1499 } 1500 // when 1501 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1502 1503 // then application sync should pass using the control plane service account 1504 assert.Equal(t, synccommon.OperationSucceeded, opState.Phase) 1505 assert.Contains(t, opState.Message, opMessage) 1506 }) 1507 1508 t.Run("app destination name instead of server URL", func(t *testing.T) { 1509 // given app sync impersonation feature is enabled with an application referring a project matching service account 1510 f := setup(true, test.FakeDestNamespace, "test-sa") 1511 opMessage := "successfully synced (no more tasks)" 1512 1513 opState := &v1alpha1.OperationState{ 1514 Operation: v1alpha1.Operation{ 1515 Sync: &v1alpha1.SyncOperation{ 1516 Source: &v1alpha1.ApplicationSource{}, 1517 }, 1518 }, 1519 Phase: synccommon.OperationRunning, 1520 } 1521 1522 f.application.Spec.Destination.Server = "" 1523 f.application.Spec.Destination.Name = "minikube" 1524 1525 // when 1526 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1527 1528 // then app sync should not fail 1529 assert.Equal(t, synccommon.OperationSucceeded, opState.Phase) 1530 assert.Contains(t, opState.Message, opMessage) 1531 }) 1532 } 1533 1534 func TestClientSideApplyMigration(t *testing.T) { 1535 t.Parallel() 1536 1537 type fixture struct { 1538 application *v1alpha1.Application 1539 project *v1alpha1.AppProject 1540 controller *ApplicationController 1541 } 1542 1543 setup := func(disableMigration bool, customManager string) *fixture { 1544 app := newFakeApp() 1545 app.Status.OperationState = nil 1546 app.Status.History = nil 1547 1548 // Add sync options 1549 if disableMigration { 1550 app.Spec.SyncPolicy.SyncOptions = append(app.Spec.SyncPolicy.SyncOptions, "DisableClientSideApplyMigration=true") 1551 } 1552 1553 // Add custom manager annotation if specified 1554 if customManager != "" { 1555 app.Annotations = map[string]string{ 1556 "argocd.argoproj.io/client-side-apply-migration-manager": customManager, 1557 } 1558 } 1559 1560 project := &v1alpha1.AppProject{ 1561 ObjectMeta: metav1.ObjectMeta{ 1562 Namespace: test.FakeArgoCDNamespace, 1563 Name: "default", 1564 }, 1565 } 1566 data := fakeData{ 1567 apps: []runtime.Object{app, project}, 1568 manifestResponse: &apiclient.ManifestResponse{ 1569 Manifests: []string{}, 1570 Namespace: test.FakeDestNamespace, 1571 Server: test.FakeClusterURL, 1572 Revision: "abc123", 1573 }, 1574 managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured), 1575 } 1576 ctrl := newFakeController(&data, nil) 1577 1578 return &fixture{ 1579 application: app, 1580 project: project, 1581 controller: ctrl, 1582 } 1583 } 1584 1585 t.Run("client-side apply migration enabled by default", func(t *testing.T) { 1586 // given 1587 t.Parallel() 1588 f := setup(false, "") 1589 1590 // when 1591 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 1592 Sync: &v1alpha1.SyncOperation{ 1593 Source: &v1alpha1.ApplicationSource{}, 1594 }, 1595 }} 1596 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1597 1598 // then 1599 assert.Equal(t, synccommon.OperationSucceeded, opState.Phase) 1600 assert.Contains(t, opState.Message, "successfully synced") 1601 }) 1602 1603 t.Run("client-side apply migration disabled", func(t *testing.T) { 1604 // given 1605 t.Parallel() 1606 f := setup(true, "") 1607 1608 // when 1609 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 1610 Sync: &v1alpha1.SyncOperation{ 1611 Source: &v1alpha1.ApplicationSource{}, 1612 }, 1613 }} 1614 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1615 1616 // then 1617 assert.Equal(t, synccommon.OperationSucceeded, opState.Phase) 1618 assert.Contains(t, opState.Message, "successfully synced") 1619 }) 1620 1621 t.Run("client-side apply migration with custom manager", func(t *testing.T) { 1622 // given 1623 t.Parallel() 1624 f := setup(false, "my-custom-manager") 1625 1626 // when 1627 opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{ 1628 Sync: &v1alpha1.SyncOperation{ 1629 Source: &v1alpha1.ApplicationSource{}, 1630 }, 1631 }} 1632 f.controller.appStateManager.SyncAppState(f.application, f.project, opState) 1633 1634 // then 1635 assert.Equal(t, synccommon.OperationSucceeded, opState.Phase) 1636 assert.Contains(t, opState.Message, "successfully synced") 1637 }) 1638 } 1639 1640 func dig(obj any, path ...any) any { 1641 i := obj 1642 1643 for _, segment := range path { 1644 switch segment := segment.(type) { 1645 case int: 1646 i = i.([]any)[segment] 1647 case string: 1648 i = i.(map[string]any)[segment] 1649 default: 1650 panic("invalid path for object") 1651 } 1652 } 1653 1654 return i 1655 }