Files
deploy/controller/sync_test.go
T
zeekayandHanzo Dev 446e343a3d cd: annotations are cd.hanzo.ai, API group is apps.hanzo.ai
Two corrections on top of the naming pass, both found by checking the live
cluster rather than the source.

The rename had silently changed the user-facing annotation key from
argocd.argoproj.io/sync-wave to cd.argoproj.io/sync-wave — 63 live
Applications carry the old key, so deploying that would have ignored every
sync-wave and broken fleet deploy ordering. The key is now cd.hanzo.ai/*,
and universe 575650286 moves the ApplicationSet template that stamps it, so
the controller regenerates all 63 consistently.

The API group in code said argoproj.io/v1alpha1 in 157 places while the
cluster has served apps.hanzo.ai/v1alpha1 since 2026-07-23 (the CRDs are
applications.apps.hanzo.ai). Code now matches what is actually served.

The legacy applications.argoproj.io/app-name label had ZERO live users, so it
went with the rest. foo.argoproj.io test fixtures became foo.example.com —
arbitrary test domains should not carry a vendor name.

go build ./... exit 0.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 16:32:14 -07:00

2328 lines
80 KiB
Go

package controller
import (
"os"
"strconv"
"testing"
openapi_v2 "github.com/google/gnostic-models/openapiv2"
"k8s.io/kubectl/pkg/util/openapi"
"sigs.k8s.io/yaml"
"github.com/hanzoai/deploy/gitops-engine/pkg/sync"
synccommon "github.com/hanzoai/deploy/gitops-engine/pkg/sync/common"
"github.com/hanzoai/deploy/gitops-engine/pkg/utils/kube"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/hanzoai/deploy/common"
"github.com/hanzoai/deploy/controller/testdata"
"github.com/hanzoai/deploy/pkg/apis/application/v1alpha1"
"github.com/hanzoai/deploy/reposerver/apiclient"
"github.com/hanzoai/deploy/test"
"github.com/hanzoai/deploy/util/cd/diff"
"github.com/hanzoai/deploy/util/cd/normalizers"
"github.com/hanzoai/deploy/util/settings"
)
type fakeDiscovery struct {
schema *openapi_v2.Document
}
func (f *fakeDiscovery) OpenAPISchema() (*openapi_v2.Document, error) {
return f.schema, nil
}
func loadCRDSchema(t *testing.T, path string) *openapi_v2.Document {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
jsonData, err := yaml.YAMLToJSON(data)
require.NoError(t, err)
doc, err := openapi_v2.ParseDocument(jsonData)
require.NoError(t, err)
return doc
}
func TestPersistRevisionHistory(t *testing.T) {
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
defaultProject := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
}
data := fakeData{
apps: []runtime.Object{app, defaultProject},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: test.FakeClusterURL,
Revision: "abc123",
},
managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
}
ctrl := newFakeController(t.Context(), &data, nil)
// Sync with source unspecified
opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{},
}}
ctrl.appStateManager.SyncAppState(t.Context(), app, defaultProject, opState)
// Ensure we record spec.source into sync result
assert.Equal(t, app.Spec.GetSource(), opState.SyncResult.Source)
updatedApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(app.Namespace).Get(t.Context(), app.Name, metav1.GetOptions{})
require.NoError(t, err)
require.Len(t, updatedApp.Status.History, 1)
assert.Equal(t, app.Spec.GetSource(), updatedApp.Status.History[0].Source)
assert.Equal(t, "abc123", updatedApp.Status.History[0].Revision)
}
func TestPersistManagedNamespaceMetadataState(t *testing.T) {
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
app.Spec.SyncPolicy.ManagedNamespaceMetadata = &v1alpha1.ManagedNamespaceMetadata{
Labels: map[string]string{
"foo": "bar",
},
Annotations: map[string]string{
"foo": "bar",
},
}
defaultProject := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
}
data := fakeData{
apps: []runtime.Object{app, defaultProject},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: test.FakeClusterURL,
Revision: "abc123",
},
managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
}
ctrl := newFakeController(t.Context(), &data, nil)
// Sync with source unspecified
opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{},
}}
ctrl.appStateManager.SyncAppState(t.Context(), app, defaultProject, opState)
// Ensure we record spec.syncPolicy.managedNamespaceMetadata into sync result
assert.Equal(t, app.Spec.SyncPolicy.ManagedNamespaceMetadata, opState.SyncResult.ManagedNamespaceMetadata)
}
func TestPersistRevisionHistoryRollback(t *testing.T) {
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
defaultProject := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
}
data := fakeData{
apps: []runtime.Object{app, defaultProject},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: test.FakeClusterURL,
Revision: "abc123",
},
managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
}
ctrl := newFakeController(t.Context(), &data, nil)
// Sync with source specified
source := v1alpha1.ApplicationSource{
Helm: &v1alpha1.ApplicationSourceHelm{
Parameters: []v1alpha1.HelmParameter{
{
Name: "test",
Value: "123",
},
},
},
}
opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &source,
},
}}
ctrl.appStateManager.SyncAppState(t.Context(), app, defaultProject, opState)
// Ensure we record opState's source into sync result
assert.Equal(t, source, opState.SyncResult.Source)
updatedApp, err := ctrl.applicationClientset.ArgoprojV1alpha1().Applications(app.Namespace).Get(t.Context(), app.Name, metav1.GetOptions{})
require.NoError(t, err)
assert.Len(t, updatedApp.Status.History, 1)
assert.Equal(t, source, updatedApp.Status.History[0].Source)
assert.Equal(t, "abc123", updatedApp.Status.History[0].Revision)
}
func TestSyncComparisonError(t *testing.T) {
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
defaultProject := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
Spec: v1alpha1.AppProjectSpec{
SignatureKeys: []v1alpha1.SignatureKey{{KeyID: "test"}}, // nolint:staticcheck
},
}
data := fakeData{
apps: []runtime.Object{app, defaultProject},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: test.FakeClusterURL,
Revision: "abc123",
SourceIntegrityResult: &v1alpha1.SourceIntegrityCheckResult{Checks: []v1alpha1.SourceIntegrityCheckResultItem{{
Name: "GIT/GPG",
Problems: []string{"Unknown key 'XXX'"},
}}},
},
managedLiveObjs: make(map[kube.ResourceKey]*unstructured.Unstructured),
}
ctrl := newFakeController(t.Context(), &data, nil)
// Sync with source unspecified
opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{},
}}
t.Setenv("CD_GPG_ENABLED", "true")
ctrl.appStateManager.SyncAppState(t.Context(), app, defaultProject, opState)
conditions := app.Status.GetConditions(map[v1alpha1.ApplicationConditionType]bool{v1alpha1.ApplicationConditionComparisonError: true})
assert.NotEmpty(t, conditions)
assert.Equal(t, "abc123", opState.SyncResult.Revision)
}
func TestAppStateManager_SyncAppState(t *testing.T) {
t.Parallel()
type fixture struct {
application *v1alpha1.Application
project *v1alpha1.AppProject
controller *ApplicationController
}
setup := func(liveObjects map[kube.ResourceKey]*unstructured.Unstructured) *fixture {
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
if liveObjects == nil {
liveObjects = make(map[kube.ResourceKey]*unstructured.Unstructured)
}
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
Spec: v1alpha1.AppProjectSpec{
SignatureKeys: []v1alpha1.SignatureKey{{KeyID: "test"}}, // nolint:staticcheck
Destinations: []v1alpha1.ApplicationDestination{
{
Namespace: "*",
Server: "*",
},
},
},
}
data := fakeData{
apps: []runtime.Object{app, project},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: test.FakeClusterURL,
Revision: "abc123",
},
managedLiveObjs: liveObjects,
}
ctrl := newFakeController(t.Context(), &data, nil)
return &fixture{
application: app,
project: project,
controller: ctrl,
}
}
t.Run("will fail the sync if finds shared resources", func(t *testing.T) {
// given
t.Parallel()
sharedObject := kube.MustToUnstructured(&corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "ConfigMap",
},
ObjectMeta: metav1.ObjectMeta{
Name: "configmap1",
Namespace: "default",
Annotations: map[string]string{
common.AnnotationKeyAppInstance: "guestbook:/ConfigMap:default/configmap1",
},
},
})
liveObjects := make(map[kube.ResourceKey]*unstructured.Unstructured)
liveObjects[kube.GetResourceKey(sharedObject)] = sharedObject
f := setup(liveObjects)
// Sync with source unspecified
opState := &v1alpha1.OperationState{Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
SyncOptions: []string{"FailOnSharedResource=true"},
},
}}
// when
f.controller.appStateManager.SyncAppState(t.Context(), f.application, f.project, opState)
// then
assert.Equal(t, synccommon.OperationFailed, opState.Phase)
assert.Contains(t, opState.Message, "ConfigMap/configmap1 is part of applications fake-cd-ns/my-app and guestbook")
})
}
func TestSyncWindowDeniesSync(t *testing.T) {
t.Parallel()
type fixture struct {
application *v1alpha1.Application
project *v1alpha1.AppProject
controller *ApplicationController
}
setup := func() *fixture {
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
Spec: v1alpha1.AppProjectSpec{
SyncWindows: v1alpha1.SyncWindows{{
Kind: "deny",
Schedule: "0 0 * * *",
Duration: "24h",
Clusters: []string{"*"},
Namespaces: []string{"*"},
Applications: []string{"*"},
}},
},
}
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(t.Context(), &data, nil)
return &fixture{
application: app,
project: project,
controller: ctrl,
}
}
t.Run("will keep the sync progressing if a sync window prevents the sync", func(t *testing.T) {
// given a project with an active deny sync window and an operation in progress
t.Parallel()
f := setup()
opMessage := "Sync operation blocked by sync window"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
// when
f.controller.appStateManager.SyncAppState(t.Context(), f.application, f.project, opState)
// then
assert.Equal(t, synccommon.OperationRunning, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
}
func TestNormalizeTargetResources(t *testing.T) {
type fixture struct {
comparisonResult *comparisonResult
}
setup := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *fixture {
t.Helper()
dc, err := diff.NewDiffConfigBuilder().
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
WithNoCache().
Build()
require.NoError(t, err)
live := test.YamlToUnstructured(testdata.LiveDeploymentYaml)
target := test.YamlToUnstructured(testdata.TargetDeploymentYaml)
return &fixture{
&comparisonResult{
reconciliationResult: sync.ReconciliationResult{
Live: []*unstructured.Unstructured{live},
Target: []*unstructured.Unstructured{target},
},
diffConfig: dc,
},
}
}
t.Run("will modify target resource adding live state in fields it should ignore", func(t *testing.T) {
// given
ignore := v1alpha1.ResourceIgnoreDifferences{
Group: "*",
Kind: "*",
ManagedFieldsManagers: []string{"janitor"},
}
ignores := []v1alpha1.ResourceIgnoreDifferences{ignore}
f := setup(t, ignores)
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, targets, 1)
iksmVersion := targets[0].GetAnnotations()["iksm-version"]
assert.Equal(t, "2.0", iksmVersion)
})
t.Run("will not copy live status into the apply target", func(t *testing.T) {
// given: status is configured as an ignored field (equivalent to the
// default ignoreResourceStatusField=crd behavior) and the live resource
// has an in-flight operationState.
ignore := v1alpha1.ResourceIgnoreDifferences{
Group: "*",
Kind: "*",
JSONPointers: []string{"/status"},
}
f := setup(t, []v1alpha1.ResourceIgnoreDifferences{ignore})
live := f.comparisonResult.reconciliationResult.Live[0]
require.NoError(t, unstructured.SetNestedField(
live.Object, "Running", "status", "operationState", "phase"))
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then: live status must not be merged into the target that gets applied,
// otherwise the SSA sync manager co-owns and freezes operationState.phase.
require.NoError(t, err)
require.Len(t, targets, 1)
_, found, err := unstructured.NestedMap(targets[0].Object, "status")
require.NoError(t, err)
assert.False(t, found, "live status must not be merged into the apply target")
})
t.Run("will not modify target resource if ignore difference is not configured", func(t *testing.T) {
// given
f := setup(t, []v1alpha1.ResourceIgnoreDifferences{})
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, targets, 1)
iksmVersion := targets[0].GetAnnotations()["iksm-version"]
assert.Equal(t, "1.0", iksmVersion)
})
t.Run("will remove fields from target if not present in live", func(t *testing.T) {
ignore := v1alpha1.ResourceIgnoreDifferences{
Group: "apps",
Kind: "Deployment",
JSONPointers: []string{"/metadata/annotations/iksm-version"},
}
ignores := []v1alpha1.ResourceIgnoreDifferences{ignore}
f := setup(t, ignores)
live := f.comparisonResult.reconciliationResult.Live[0]
unstructured.RemoveNestedField(live.Object, "metadata", "annotations", "iksm-version")
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, targets, 1)
_, ok := targets[0].GetAnnotations()["iksm-version"]
assert.False(t, ok)
})
t.Run("will correctly normalize with multiple ignore configurations", func(t *testing.T) {
// given
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "apps",
Kind: "Deployment",
JSONPointers: []string{"/spec/replicas"},
},
{
Group: "*",
Kind: "*",
ManagedFieldsManagers: []string{"janitor"},
},
}
f := setup(t, ignores)
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, targets, 1)
normalized := targets[0]
iksmVersion, ok := normalized.GetAnnotations()["iksm-version"]
require.True(t, ok)
assert.Equal(t, "2.0", iksmVersion)
replicas, ok, err := unstructured.NestedInt64(normalized.Object, "spec", "replicas")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, int64(4), replicas)
})
t.Run("will keep new array entries not found in live state if not ignored", func(t *testing.T) {
// given
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "apps",
Kind: "Deployment",
JQPathExpressions: []string{".spec.template.spec.containers[] | select(.name == \"guestbook-ui\")"},
},
}
f := setup(t, ignores)
target := test.YamlToUnstructured(testdata.TargetDeploymentNewEntries)
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target}
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, targets, 1)
containers, ok, err := unstructured.NestedSlice(targets[0].Object, "spec", "template", "spec", "containers")
require.NoError(t, err)
require.True(t, ok)
assert.Len(t, containers, 2)
})
}
func TestNormalizeTargetResourcesWithList(t *testing.T) {
type fixture struct {
comparisonResult *comparisonResult
}
setupHTTPProxy := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *fixture {
t.Helper()
dc, err := diff.NewDiffConfigBuilder().
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
WithNoCache().
Build()
require.NoError(t, err)
live := test.YamlToUnstructured(testdata.LiveHTTPProxy)
target := test.YamlToUnstructured(testdata.TargetHTTPProxy)
return &fixture{
&comparisonResult{
reconciliationResult: sync.ReconciliationResult{
Live: []*unstructured.Unstructured{live},
Target: []*unstructured.Unstructured{target},
},
diffConfig: dc,
},
}
}
t.Run("will properly ignore nested fields within arrays", func(t *testing.T) {
doc := loadCRDSchema(t, "testdata/schemas/httpproxy_openapi_v2.yaml")
disco := &fakeDiscovery{schema: doc}
oapiGetter := openapi.NewOpenAPIGetter(disco)
oapiResources, err := openapi.NewOpenAPIParser(oapiGetter).Parse()
require.NoError(t, err)
// given
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "projectcontour.io",
Kind: "HTTPProxy",
JQPathExpressions: []string{".spec.routes[]"},
// JSONPointers: []string{"/spec/routes"},
},
}
f := setupHTTPProxy(t, ignores)
target := test.YamlToUnstructured(testdata.TargetHTTPProxy)
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target}
// when
patchedTargets, err := normalizeTargetResources(oapiResources, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, f.comparisonResult.reconciliationResult.Live, 1)
require.Len(t, f.comparisonResult.reconciliationResult.Target, 1)
require.Len(t, patchedTargets, 1)
// live should have 1 entry
require.Len(t, dig(f.comparisonResult.reconciliationResult.Live[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors"), 1)
// assert some arbitrary field to show `entries[0]` is not an empty object
require.Equal(t, "sample-header", dig(f.comparisonResult.reconciliationResult.Live[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries", 0, "requestHeader", "headerName"))
// target has 2 entries
require.Len(t, dig(f.comparisonResult.reconciliationResult.Target[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries"), 2)
// assert some arbitrary field to show `entries[0]` is not an empty object
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"))
// It should be *1* entries in the array
require.Len(t, dig(patchedTargets[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors"), 1)
// and it should NOT equal an empty object
require.Len(t, dig(patchedTargets[0].Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries", 0), 1)
})
t.Run("will correctly set array entries if new entries have been added", func(t *testing.T) {
// given
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "apps",
Kind: "Deployment",
JQPathExpressions: []string{".spec.template.spec.containers[].env[] | select(.name == \"SOME_ENV_VAR\")"},
},
}
f := setupHTTPProxy(t, ignores)
live := test.YamlToUnstructured(testdata.LiveDeploymentEnvVarsYaml)
target := test.YamlToUnstructured(testdata.TargetDeploymentEnvVarsYaml)
f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{live}
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target}
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, targets, 1)
containers, ok, err := unstructured.NestedSlice(targets[0].Object, "spec", "template", "spec", "containers")
require.NoError(t, err)
require.True(t, ok)
assert.Len(t, containers, 1)
ports := containers[0].(map[string]any)["ports"].([]any)
assert.Len(t, ports, 1)
env := containers[0].(map[string]any)["env"].([]any)
assert.Len(t, env, 3)
first := env[0]
second := env[1]
third := env[2]
// Currently the defined order at this time is the insertion order of the target manifest.
assert.Equal(t, "SOME_ENV_VAR", first.(map[string]any)["name"])
assert.Equal(t, "some_value", first.(map[string]any)["value"])
assert.Equal(t, "SOME_OTHER_ENV_VAR", second.(map[string]any)["name"])
assert.Equal(t, "some_other_value", second.(map[string]any)["value"])
assert.Equal(t, "YET_ANOTHER_ENV_VAR", third.(map[string]any)["name"])
assert.Equal(t, "yet_another_value", third.(map[string]any)["value"])
})
t.Run("ignore-deployment-image-replicas-changes-additive", func(t *testing.T) {
// given
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "apps",
Kind: "Deployment",
JSONPointers: []string{"/spec/replicas"},
}, {
Group: "apps",
Kind: "Deployment",
JQPathExpressions: []string{".spec.template.spec.containers[].image"},
},
}
f := setupHTTPProxy(t, ignores)
live := test.YamlToUnstructured(testdata.MinimalImageReplicaDeploymentYaml)
target := test.YamlToUnstructured(testdata.AdditionalImageReplicaDeploymentYaml)
f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{live}
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target}
// when
targets, err := normalizeTargetResources(nil, f.comparisonResult)
// then
require.NoError(t, err)
require.Len(t, targets, 1)
metadata, ok, err := unstructured.NestedMap(targets[0].Object, "metadata")
require.NoError(t, err)
require.True(t, ok)
labels, ok := metadata["labels"].(map[string]any)
require.True(t, ok)
assert.Len(t, labels, 2)
assert.Equal(t, "web", labels["appProcess"])
spec, ok, err := unstructured.NestedMap(targets[0].Object, "spec")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, int64(1), spec["replicas"])
template, ok := spec["template"].(map[string]any)
require.True(t, ok)
tMetadata, ok := template["metadata"].(map[string]any)
require.True(t, ok)
tLabels, ok := tMetadata["labels"].(map[string]any)
require.True(t, ok)
assert.Len(t, tLabels, 2)
assert.Equal(t, "web", tLabels["appProcess"])
tSpec, ok := template["spec"].(map[string]any)
require.True(t, ok)
containers, ok, err := unstructured.NestedSlice(tSpec, "containers")
require.NoError(t, err)
require.True(t, ok)
assert.Len(t, containers, 1)
first := containers[0].(map[string]any)
assert.Equal(t, "alpine:3", first["image"])
resources, ok := first["resources"].(map[string]any)
require.True(t, ok)
requests, ok := resources["requests"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "400m", requests["cpu"])
env, ok, err := unstructured.NestedSlice(first, "env")
require.NoError(t, err)
require.True(t, ok)
assert.Len(t, env, 1)
env0 := env[0].(map[string]any)
assert.Equal(t, "EV", env0["name"])
assert.Equal(t, "here", env0["value"])
})
t.Run("patches ignored differences in individual array elements of HTTPProxy CRD", func(t *testing.T) {
doc := loadCRDSchema(t, "testdata/schemas/httpproxy_openapi_v2.yaml")
disco := &fakeDiscovery{schema: doc}
oapiGetter := openapi.NewOpenAPIGetter(disco)
oapiResources, err := openapi.NewOpenAPIParser(oapiGetter).Parse()
require.NoError(t, err)
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "projectcontour.io",
Kind: "HTTPProxy",
JQPathExpressions: []string{".spec.routes[].rateLimitPolicy.global.descriptors[].entries[]"},
},
}
f := setupHTTPProxy(t, ignores)
target := test.YamlToUnstructured(testdata.TargetHTTPProxy)
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target}
live := test.YamlToUnstructured(testdata.LiveHTTPProxy)
f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{live}
patchedTargets, err := normalizeTargetResources(oapiResources, f.comparisonResult)
require.NoError(t, err)
require.Len(t, patchedTargets, 1)
patched := patchedTargets[0]
// verify descriptors array in patched target
descriptors := dig(patched.Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors").([]any)
require.Len(t, descriptors, 1) // Only the descriptors with ignored entries should remain
// verify individual entries array inside the descriptor
entriesArr := dig(patched.Object, "spec", "routes", 0, "rateLimitPolicy", "global", "descriptors", 0, "entries").([]any)
require.Len(t, entriesArr, 1) // Only the ignored entry should be patched
// verify the content of the entry is preserved correctly
entry := entriesArr[0].(map[string]any)
requestHeader := entry["requestHeader"].(map[string]any)
assert.Equal(t, "sample-header", requestHeader["headerName"])
assert.Equal(t, "sample-key", requestHeader["descriptorKey"])
})
}
func TestNormalizeTargetResourcesCRDs(t *testing.T) {
type fixture struct {
comparisonResult *comparisonResult
}
setupHTTPProxy := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *fixture {
t.Helper()
dc, err := diff.NewDiffConfigBuilder().
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
WithNoCache().
Build()
require.NoError(t, err)
live := test.YamlToUnstructured(testdata.SimpleAppLiveYaml)
target := test.YamlToUnstructured(testdata.SimpleAppTargetYaml)
return &fixture{
&comparisonResult{
reconciliationResult: sync.ReconciliationResult{
Live: []*unstructured.Unstructured{live},
Target: []*unstructured.Unstructured{target},
},
diffConfig: dc,
},
}
}
t.Run("sample-app", func(t *testing.T) {
doc := loadCRDSchema(t, "testdata/schemas/simple-app.yaml")
disco := &fakeDiscovery{schema: doc}
oapiGetter := openapi.NewOpenAPIGetter(disco)
oapiResources, err := openapi.NewOpenAPIParser(oapiGetter).Parse()
require.NoError(t, err)
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "example.com",
Kind: "SimpleApp",
JQPathExpressions: []string{".spec.servers[1].enabled", ".spec.servers[0].port"},
},
}
f := setupHTTPProxy(t, ignores)
target := test.YamlToUnstructured(testdata.SimpleAppTargetYaml)
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target}
live := test.YamlToUnstructured(testdata.SimpleAppLiveYaml)
f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{live}
patchedTargets, err := normalizeTargetResources(oapiResources, f.comparisonResult)
require.NoError(t, err)
require.Len(t, patchedTargets, 1)
patched := patchedTargets[0]
require.NotNil(t, patched)
// 'spec.servers' array has length 2
servers := dig(patched.Object, "spec", "servers").([]any)
require.Len(t, servers, 2)
// first server's 'name' is 'server1'
name1 := dig(patched.Object, "spec", "servers", 0, "name").(string)
assert.Equal(t, "server1", name1)
assert.Equal(t, int64(8081), dig(patched.Object, "spec", "servers", 0, "port").(int64))
assert.Equal(t, int64(9090), dig(patched.Object, "spec", "servers", 1, "port").(int64))
// first server's 'enabled' should be true
enabled1 := dig(patched.Object, "spec", "servers", 0, "enabled").(bool)
assert.True(t, enabled1)
// second server's 'name' should be 'server2'
name2 := dig(patched.Object, "spec", "servers", 1, "name").(string)
assert.Equal(t, "server2", name2)
// second server's 'enabled' should be true (respected from live due to ignoreDifferences)
enabled2 := dig(patched.Object, "spec", "servers", 1, "enabled").(bool)
assert.True(t, enabled2)
})
t.Run("rollout-obj", func(t *testing.T) {
// Load Rollout CRD schema like SimpleApp
doc := loadCRDSchema(t, "testdata/schemas/rollout-schema.yaml")
disco := &fakeDiscovery{schema: doc}
oapiGetter := openapi.NewOpenAPIGetter(disco)
oapiResources, err := openapi.NewOpenAPIParser(oapiGetter).Parse()
require.NoError(t, err)
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "apps.hanzo.ai",
Kind: "Rollout",
JQPathExpressions: []string{`.spec.template.spec.containers[] | select(.name == "init") | .image`},
},
}
f := setupHTTPProxy(t, ignores)
live := test.YamlToUnstructured(testdata.LiveRolloutYaml)
target := test.YamlToUnstructured(testdata.TargetRolloutYaml)
f.comparisonResult.reconciliationResult.Live = []*unstructured.Unstructured{live}
f.comparisonResult.reconciliationResult.Target = []*unstructured.Unstructured{target}
targets, err := normalizeTargetResources(oapiResources, f.comparisonResult)
require.NoError(t, err)
require.Len(t, targets, 1)
patched := targets[0]
require.NotNil(t, patched)
containers := dig(patched.Object, "spec", "template", "spec", "containers").([]any)
require.Len(t, containers, 2)
initContainer := containers[0].(map[string]any)
mainContainer := containers[1].(map[string]any)
// Assert init container image is preserved (ignoreDifferences works)
initImage := dig(initContainer, "image").(string)
assert.Equal(t, "init-container:v1", initImage)
// Assert main container fields as expected
mainName := dig(mainContainer, "name").(string)
assert.Equal(t, "main", mainName)
mainImage := dig(mainContainer, "image").(string)
assert.Equal(t, "main-container:v1", mainImage)
})
t.Run("app-of-apps-child-application-chart-version-update-panic", func(t *testing.T) {
// This test reproduces the EXACT panic from https://github.com/blakepettersson/broken-app-of-apps
// Scenario:
// 1. Parent app-of-apps has RespectIgnoreDifferences=true
// 2. It manages a child Application (argo-workflows Helm chart)
// 3. Initial sync works fine (chart version 0.45.28)
// 4. Subsequent update (chart version 0.45.28 -> 0.45.29) causes panic
//
// The panic occurs when the OpenAPI schema from the Application CRD is used
// to perform strategic merge patch operations on array fields with incomplete merge directives
doc := loadCRDSchema(t, "testdata/schemas/application-openapi-v2.yaml")
disco := &fakeDiscovery{schema: doc}
oapiGetter := openapi.NewOpenAPIGetter(disco)
oapiResources, err := openapi.NewOpenAPIParser(oapiGetter).Parse()
require.NoError(t, err)
// Set up ignoreDifferences with RespectIgnoreDifferences enabled
// This is typical for app-of-apps to ignore certain fields
ignores := []v1alpha1.ResourceIgnoreDifferences{
{
Group: "apps.hanzo.ai",
Kind: "Application",
JQPathExpressions: []string{".spec.syncPolicy.automated"},
},
}
dc, err := diff.NewDiffConfigBuilder().
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
WithNoCache().
Build()
require.NoError(t, err)
// Load live child Application (current state with chart version 0.45.28)
live := test.YamlToUnstructured(testdata.LiveChildApplicationYaml)
// Load target child Application (desired state with chart version 0.45.29)
target := test.YamlToUnstructured(testdata.TargetChildApplicationYaml)
comparisonResult := &comparisonResult{
reconciliationResult: sync.ReconciliationResult{
Live: []*unstructured.Unstructured{live},
Target: []*unstructured.Unstructured{target},
},
diffConfig: dc,
}
// This should NOT panic - it should handle the case gracefully
// Without the fix, this panics with: "runtime error: invalid memory address or nil pointer dereference"
// at k8s.io/apimachinery/pkg/util/strategicpatch.handleSliceDiff:306
// when processing array fields in the Application spec (like syncOptions)
patchedTargets, err := normalizeTargetResources(oapiResources, comparisonResult)
// We expect either success or a graceful error, but NOT a panic
if err != nil {
// If there's an error, it should be a descriptive error, not a panic
t.Logf("Got error (non-panic): %v", err)
} else {
// If successful, verify we got a result
require.Len(t, patchedTargets, 1)
patched := patchedTargets[0]
require.NotNil(t, patched)
// Verify the chart version was updated
targetRevision, ok, err := unstructured.NestedString(patched.Object, "spec", "source", "targetRevision")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "0.45.28", targetRevision, "Should have the updated chart version from target")
}
})
}
// TestNormalizeTargetResourcesPDBSelector reproduces https://github.com/argoproj/argo-cd/issues/18232
// When a PDB (policy/v1) has an ignoreDifferences rule for a matchLabels sub-field and
// RespectIgnoreDifferences=true is set, normalizeTargetResources should only patch the
// ignored field — not clobber the entire selector due to patchStrategy:"replace".
func TestNormalizeTargetResourcesPDBSelector(t *testing.T) {
setupPDB := func(t *testing.T, ignores []v1alpha1.ResourceIgnoreDifferences) *comparisonResult {
t.Helper()
dc, err := diff.NewDiffConfigBuilder().
WithDiffSettings(ignores, nil, true, normalizers.IgnoreNormalizerOpts{}).
WithNoCache().
Build()
require.NoError(t, err)
live := test.YamlToUnstructured(testdata.LivePDBYaml)
target := test.YamlToUnstructured(testdata.TargetPDBYaml)
return &comparisonResult{
reconciliationResult: sync.ReconciliationResult{
Live: []*unstructured.Unstructured{live},
Target: []*unstructured.Unstructured{target},
},
diffConfig: dc,
}
}
t.Run("ignoring one matchLabels key should not clobber other selector changes", func(t *testing.T) {
// User ignores /spec/selector/matchLabels/version but intentionally changes
// /spec/selector/matchLabels/app from "myapp" to "myapp-v2".
// With patchStrategy:"replace" on the selector field (policy/v1),
// normalizeTargetResources should preserve the app label change.
cr := setupPDB(t, []v1alpha1.ResourceIgnoreDifferences{
{
Group: "policy",
Kind: "PodDisruptionBudget",
JSONPointers: []string{"/spec/selector/matchLabels/version"},
},
})
targets, err := normalizeTargetResources(nil, cr)
require.NoError(t, err)
require.Len(t, targets, 1)
matchLabels, ok, err := unstructured.NestedStringMap(targets[0].Object, "spec", "selector", "matchLabels")
require.NoError(t, err)
require.True(t, ok)
// The "version" label should take the live value (ignored field — respected).
assert.Equal(t, "v1", matchLabels["version"], "ignored field 'version' should use live value")
// The "app" label should keep the target value — it is NOT ignored.
assert.Equal(t, "myapp-v2", matchLabels["app"],
"non-ignored field 'app' was clobbered by live value; patchStrategy:replace on selector may cause normalizeTargetResources to overwrite the entire selector")
})
t.Run("ignoring one matchLabels key should not drop non-ignored sibling fields", func(t *testing.T) {
// Target has matchExpressions that live does not.
// Ignoring a matchLabels key should not cause matchExpressions to be
// dropped due to replace semantics pulling the entire live selector
// (which lacks matchExpressions) into the target.
cr := setupPDB(t, []v1alpha1.ResourceIgnoreDifferences{
{
Group: "policy",
Kind: "PodDisruptionBudget",
JSONPointers: []string{"/spec/selector/matchLabels/version"},
},
})
// Add matchExpressions to the target only (not in live).
target := cr.reconciliationResult.Target[0]
matchExpr := []any{
map[string]any{
"key": "tier",
"operator": "In",
"values": []any{"frontend"},
},
}
require.NoError(t, unstructured.SetNestedSlice(target.Object, matchExpr, "spec", "selector", "matchExpressions"))
targets, err := normalizeTargetResources(nil, cr)
require.NoError(t, err)
require.Len(t, targets, 1)
// matchExpressions should be preserved — it was not ignored.
expr, ok, err := unstructured.NestedSlice(targets[0].Object, "spec", "selector", "matchExpressions")
require.NoError(t, err)
assert.True(t, ok, "matchExpressions was dropped from target by replace-strategy collateral")
assert.Len(t, expr, 1)
// app label should still reflect the target change.
matchLabels, ok, err := unstructured.NestedStringMap(targets[0].Object, "spec", "selector", "matchLabels")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "myapp-v2", matchLabels["app"])
assert.Equal(t, "v1", matchLabels["version"])
})
t.Run("ignoring one matchLabels key should not add live-only non-ignored selector keys", func(t *testing.T) {
// Live has an additional non-ignored label ("track") that target does not.
// Replace semantics should not copy it into the patched target.
cr := setupPDB(t, []v1alpha1.ResourceIgnoreDifferences{
{
Group: "policy",
Kind: "PodDisruptionBudget",
JSONPointers: []string{"/spec/selector/matchLabels/version"},
},
})
live := cr.reconciliationResult.Live[0]
matchLabels, ok, err := unstructured.NestedStringMap(live.Object, "spec", "selector", "matchLabels")
require.NoError(t, err)
require.True(t, ok)
matchLabels["track"] = "stable"
require.NoError(t, unstructured.SetNestedStringMap(live.Object, matchLabels, "spec", "selector", "matchLabels"))
targets, err := normalizeTargetResources(nil, cr)
require.NoError(t, err)
require.Len(t, targets, 1)
patchedMatchLabels, ok, err := unstructured.NestedStringMap(targets[0].Object, "spec", "selector", "matchLabels")
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, "myapp-v2", patchedMatchLabels["app"])
assert.Equal(t, "v1", patchedMatchLabels["version"])
_, found := patchedMatchLabels["track"]
assert.False(t, found, "live-only non-ignored selector key was added to target by replace-strategy collateral")
})
t.Run("ignoring entire selector should replace target selector with live", func(t *testing.T) {
cr := setupPDB(t, []v1alpha1.ResourceIgnoreDifferences{
{
Group: "policy",
Kind: "PodDisruptionBudget",
JSONPointers: []string{"/spec/selector"},
},
})
targets, err := normalizeTargetResources(nil, cr)
require.NoError(t, err)
require.Len(t, targets, 1)
matchLabels, ok, err := unstructured.NestedStringMap(targets[0].Object, "spec", "selector", "matchLabels")
require.NoError(t, err)
require.True(t, ok)
// When the entire selector is ignored, both labels should come from live.
assert.Equal(t, "myapp", matchLabels["app"])
assert.Equal(t, "v1", matchLabels["version"])
})
}
func TestDeriveServiceAccountMatchingNamespaces(t *testing.T) {
t.Parallel()
type fixture struct {
project *v1alpha1.AppProject
application *v1alpha1.Application
cluster *v1alpha1.Cluster
}
setup := func(destinationServiceAccounts []v1alpha1.ApplicationDestinationServiceAccount, destinationNamespace, destinationServerURL, applicationNamespace string) *fixture {
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: "cd-ns",
Name: "testProj",
},
Spec: v1alpha1.AppProjectSpec{
DestinationServiceAccounts: destinationServiceAccounts,
},
}
app := &v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Namespace: applicationNamespace,
Name: "testApp",
},
Spec: v1alpha1.ApplicationSpec{
Project: "testProj",
Destination: v1alpha1.ApplicationDestination{
Server: destinationServerURL,
Namespace: destinationNamespace,
},
},
}
cluster := &v1alpha1.Cluster{
Server: "https://kubernetes.svc.local",
Name: "test-cluster",
}
return &fixture{
project: project,
application: app,
cluster: cluster,
}
}
t.Run("empty destination service accounts", func(t *testing.T) {
// given an application referring a project with no destination service accounts
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := ""
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
assert.Equal(t, expectedSA, sa)
assert.NoError(t, err)
})
t.Run("exact match of destination namespace", func(t *testing.T) {
// given an application referring a project with exactly one destination service account that matches the application destination,
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should be no error and should use the right service account for impersonation
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("exact one match with multiple destination service accounts", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts having one exact match for application destination
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "guestbook",
DefaultServiceAccount: "guestbook-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "guestbook-test",
DefaultServiceAccount: "guestbook-test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should be no error and should use the right service account for impersonation
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("first match to be used when multiple matches are available", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts having multiple match for application destination
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa-3",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "guestbook",
DefaultServiceAccount: "guestbook-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should be no error and it should use the first matching service account for impersonation
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("first match to be used when glob pattern is used", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts with glob patterns matching the application destination
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "test*",
DefaultServiceAccount: "test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should not be any error and should use the first matching glob pattern service account for impersonation
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("no match among a valid list", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts with no matches for application destination
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "test1",
DefaultServiceAccount: "test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "test2",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := ""
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("app destination namespace is empty", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts with empty application destination namespace
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
DefaultServiceAccount: "test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "*",
DefaultServiceAccount: "test-sa-2",
},
}
destinationNamespace := ""
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:cd-ns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should not be any error and the service account configured for with empty namespace should be used.
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("match done via catch all glob pattern", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts having a catch all glob pattern
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "testns1",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "*",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should not be any error and the catch all service account should be returned
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("match done via invalid glob pattern", func(t *testing.T) {
// given an application referring a project with a destination service account having an invalid glob pattern for namespace
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "e[[a*",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := ""
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there must be an error as the glob pattern is invalid.
require.ErrorContains(t, err, "invalid glob pattern for destination namespace")
assert.Equal(t, expectedSA, sa)
})
t.Run("sa specified with a namespace", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts having a matching service account specified with its namespace
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "myns:test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "*",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:myns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
assert.Equal(t, expectedSA, sa)
// then, there should not be any error and the service account with its namespace should be returned.
require.NoError(t, err)
})
t.Run("app destination name instead of server URL", func(t *testing.T) {
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "*",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// Use destination name instead of server URL
f.application.Spec.Destination.Server = ""
f.application.Spec.Destination.Name = f.cluster.Name
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
assert.Equal(t, expectedSA, sa)
// then, there should not be any error and the service account with its namespace should be returned.
require.NoError(t, err)
})
}
func TestDeriveServiceAccountMatchingServers(t *testing.T) {
t.Parallel()
type fixture struct {
project *v1alpha1.AppProject
application *v1alpha1.Application
cluster *v1alpha1.Cluster
}
setup := func(destinationServiceAccounts []v1alpha1.ApplicationDestinationServiceAccount, destinationNamespace, destinationServerURL, applicationNamespace string) *fixture {
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: "cd-ns",
Name: "testProj",
},
Spec: v1alpha1.AppProjectSpec{
DestinationServiceAccounts: destinationServiceAccounts,
},
}
app := &v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Namespace: applicationNamespace,
Name: "testApp",
},
Spec: v1alpha1.ApplicationSpec{
Project: "testProj",
Destination: v1alpha1.ApplicationDestination{
Server: destinationServerURL,
Namespace: destinationNamespace,
},
},
}
cluster := &v1alpha1.Cluster{
Server: "https://kubernetes.svc.local",
Name: "test-cluster",
}
return &fixture{
project: project,
application: app,
cluster: cluster,
}
}
t.Run("exact one match with multiple destination service accounts", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts and one exact match for application destination
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "guestbook",
DefaultServiceAccount: "guestbook-sa",
},
{
Server: "https://abc.svc.local",
Namespace: "guestbook",
DefaultServiceAccount: "guestbook-test-sa",
},
{
Server: "https://cde.svc.local",
Namespace: "guestbook",
DefaultServiceAccount: "default-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should not be any error and the right service account must be returned.
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("first match to be used when multiple matches are available", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts and multiple matches for application destination
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "guestbook",
DefaultServiceAccount: "guestbook-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should not be any error and first matching service account should be used
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("first match to be used when glob pattern is used", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts with a matching glob pattern and exact match
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "test*",
DefaultServiceAccount: "test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
assert.Equal(t, expectedSA, sa)
// then, there should not be any error and the service account of the glob pattern, being the first match should be returned.
require.NoError(t, err)
})
t.Run("no match among a valid list", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts with no match
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa",
},
{
Server: "https://abc.svc.local",
Namespace: "testns",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://cde.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://xyz.svc.local"
applicationNamespace := "cd-ns"
expectedSA := ""
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, &v1alpha1.Cluster{Server: destinationServerURL})
// then, there an error with appropriate message must be returned
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("match done via catch all glob pattern", func(t *testing.T) {
// given an application referring a project with multiple destination service accounts with matching catch all glob pattern
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "testns1",
DefaultServiceAccount: "test-sa-2",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
{
Server: "*",
Namespace: "*",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://localhost:6443"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there should not be any error and the service account of the glob pattern match must be returned.
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("match done via invalid glob pattern", func(t *testing.T) {
// given an application referring a project with a destination service account having an invalid glob pattern for server
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "e[[a*",
Namespace: "test-ns",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := ""
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
// then, there must be an error as the glob pattern is invalid.
require.ErrorContains(t, err, "invalid glob pattern for destination server")
assert.Equal(t, expectedSA, sa)
})
t.Run("sa specified with a namespace", func(t *testing.T) {
// given app sync impersonation feature is enabled and matching service account is prefixed with a namespace
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://abc.svc.local",
Namespace: "testns",
DefaultServiceAccount: "myns:test-sa",
},
{
Server: "https://kubernetes.svc.local",
Namespace: "default",
DefaultServiceAccount: "default-sa",
},
{
Server: "*",
Namespace: "*",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://abc.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:myns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, &v1alpha1.Cluster{Server: destinationServerURL})
// then, there should not be any error and the service account with the given namespace prefix must be returned.
require.NoError(t, err)
assert.Equal(t, expectedSA, sa)
})
t.Run("app destination name instead of server URL", func(t *testing.T) {
t.Parallel()
destinationServiceAccounts := []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://kubernetes.svc.local",
Namespace: "*",
DefaultServiceAccount: "test-sa",
},
}
destinationNamespace := "testns"
destinationServerURL := "https://kubernetes.svc.local"
applicationNamespace := "cd-ns"
expectedSA := "system:serviceaccount:testns:test-sa"
f := setup(destinationServiceAccounts, destinationNamespace, destinationServerURL, applicationNamespace)
// Use destination name instead of server URL
f.application.Spec.Destination.Server = ""
f.application.Spec.Destination.Name = f.cluster.Name
// when
sa, err := settings.DeriveServiceAccountToImpersonate(f.project, f.application, f.cluster)
assert.Equal(t, expectedSA, sa)
// then, there should not be any error and the service account with its namespace should be returned.
require.NoError(t, err)
})
}
func TestSyncWithImpersonate(t *testing.T) {
type fixture struct {
application *v1alpha1.Application
project *v1alpha1.AppProject
controller *ApplicationController
}
setup := func(impersonationEnabled bool, destinationNamespace, serviceAccountName string) *fixture {
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
Spec: v1alpha1.AppProjectSpec{
DestinationServiceAccounts: []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://localhost:6443",
Namespace: destinationNamespace,
DefaultServiceAccount: serviceAccountName,
},
},
},
}
additionalObjs := []runtime.Object{}
if serviceAccountName != "" {
syncServiceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: serviceAccountName,
Namespace: test.FakeDestNamespace,
},
}
additionalObjs = append(additionalObjs, syncServiceAccount)
}
data := fakeData{
apps: []runtime.Object{app, project},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: "https://localhost:6443",
Revision: "abc123",
},
managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{},
configMapData: map[string]string{
"application.sync.impersonation.enabled": strconv.FormatBool(impersonationEnabled),
},
additionalObjs: additionalObjs,
}
ctrl := newFakeController(t.Context(), &data, nil)
return &fixture{
application: app,
project: project,
controller: ctrl,
}
}
t.Run("sync with impersonation and no matching service account", func(t *testing.T) {
// given app sync impersonation feature is enabled with enforcement (default) and no matching service account
f := setup(true, test.FakeArgoCDNamespace, "")
opMessage := "no matching service account found for destination server https://localhost:6443 and namespace fake-dest-ns"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
// when
f.controller.appStateManager.SyncAppState(t.Context(), f.application, f.project, opState)
// then, app sync should fail with expected error message in operation state
assert.Equal(t, synccommon.OperationError, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
t.Run("sync with impersonation and empty service account match", func(t *testing.T) {
// given app sync impersonation feature is enabled with enforcement (default) and a project with empty service account configured
f := setup(true, test.FakeDestNamespace, "")
opMessage := "default service account contains invalid chars"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
// when
f.controller.appStateManager.SyncAppState(t.Context(), f.application, f.project, opState)
// then app sync should fail with expected error message in operation state
assert.Equal(t, synccommon.OperationError, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
t.Run("sync with impersonation and matching sa", func(t *testing.T) {
// given app sync impersonation feature is enabled with an application referring a project matching service account
f := setup(true, test.FakeDestNamespace, "test-sa")
opMessage := "successfully synced (no more tasks)"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
// when
f.controller.appStateManager.SyncAppState(t.Context(), f.application, f.project, opState)
// then app sync should not fail
assert.Equal(t, synccommon.OperationSucceeded, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
t.Run("sync without impersonation", func(t *testing.T) {
// given app sync impersonation feature is disabled with an application referring a project matching service account
f := setup(false, test.FakeDestNamespace, "")
opMessage := "successfully synced (no more tasks)"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
// when
f.controller.appStateManager.SyncAppState(t.Context(), f.application, f.project, opState)
// then application sync should pass using the control plane service account
assert.Equal(t, synccommon.OperationSucceeded, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
t.Run("sync with impersonation enabled, enforcement disabled, no matching SA", func(t *testing.T) {
// given app sync impersonation feature is enabled with enforcement disabled and no matching service account
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
Spec: v1alpha1.AppProjectSpec{
DestinationServiceAccounts: []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://localhost:6443",
Namespace: test.FakeArgoCDNamespace,
DefaultServiceAccount: "",
},
},
},
}
data := fakeData{
apps: []runtime.Object{app, project},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: "https://localhost:6443",
Revision: "abc123",
},
managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{},
configMapData: map[string]string{
"application.sync.impersonation.enabled": strconv.FormatBool(true),
"application.sync.impersonation.enforced": strconv.FormatBool(false),
},
additionalObjs: []runtime.Object{},
}
ctrl := newFakeController(t.Context(), &data, nil)
opMessage := "successfully synced (no more tasks)"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
// when
ctrl.appStateManager.SyncAppState(t.Context(), app, project, opState)
// then app sync should succeed with fallback to controller SA
assert.Equal(t, synccommon.OperationSucceeded, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
t.Run("sync with impersonation enabled, enforcement disabled, matching SA", func(t *testing.T) {
// given app sync impersonation feature is enabled with enforcement disabled and matching service account
app := newFakeApp()
app.Status.OperationState = nil
app.Status.History = nil
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.FakeArgoCDNamespace,
Name: "default",
},
Spec: v1alpha1.AppProjectSpec{
DestinationServiceAccounts: []v1alpha1.ApplicationDestinationServiceAccount{
{
Server: "https://localhost:6443",
Namespace: test.FakeDestNamespace,
DefaultServiceAccount: "test-sa",
},
},
},
}
syncServiceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sa",
Namespace: test.FakeDestNamespace,
},
}
data := fakeData{
apps: []runtime.Object{app, project},
manifestResponse: &apiclient.ManifestResponse{
Manifests: []string{},
Namespace: test.FakeDestNamespace,
Server: "https://localhost:6443",
Revision: "abc123",
},
managedLiveObjs: map[kube.ResourceKey]*unstructured.Unstructured{},
configMapData: map[string]string{
"application.sync.impersonation.enabled": strconv.FormatBool(true),
"application.sync.impersonation.enforced": strconv.FormatBool(false),
},
additionalObjs: []runtime.Object{syncServiceAccount},
}
ctrl := newFakeController(t.Context(), &data, nil)
opMessage := "successfully synced (no more tasks)"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
// when
ctrl.appStateManager.SyncAppState(t.Context(), app, project, opState)
// then app sync should succeed using impersonation
assert.Equal(t, synccommon.OperationSucceeded, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
t.Run("app destination name instead of server URL", func(t *testing.T) {
// given app sync impersonation feature is enabled with an application referring a project matching service account
f := setup(true, test.FakeDestNamespace, "test-sa")
opMessage := "successfully synced (no more tasks)"
opState := &v1alpha1.OperationState{
Operation: v1alpha1.Operation{
Sync: &v1alpha1.SyncOperation{
Source: &v1alpha1.ApplicationSource{},
},
},
Phase: synccommon.OperationRunning,
}
f.application.Spec.Destination.Server = ""
f.application.Spec.Destination.Name = "minikube"
// when
f.controller.appStateManager.SyncAppState(t.Context(), f.application, f.project, opState)
// then app sync should not fail
assert.Equal(t, synccommon.OperationSucceeded, opState.Phase)
assert.Contains(t, opState.Message, opMessage)
})
}
func TestClientSideApplyMigration(t *testing.T) {
t.Parallel()
type fixture struct {
application *v1alpha1.Application
project *v1alpha1.AppProject
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{
"cd.hanzo.ai/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(t.Context(), &data, nil)
return &fixture{
application: app,
project: project,
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(t.Context(), f.application, f.project, opState)
// then
assert.Equal(t, synccommon.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(t.Context(), f.application, f.project, opState)
// then
assert.Equal(t, synccommon.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(t.Context(), f.application, f.project, opState)
// then
assert.Equal(t, synccommon.OperationSucceeded, opState.Phase)
assert.Contains(t, opState.Message, "successfully synced")
})
}
func dig(obj any, path ...any) any {
i := obj
for _, segment := range path {
switch segment := segment.(type) {
case int:
i = i.([]any)[segment]
case string:
i = i.(map[string]any)[segment]
default:
panic("invalid path for object")
}
}
return i
}
func TestValidateSyncPermissions(t *testing.T) {
t.Parallel()
newResource := func(group, kind, name, namespace string) *unstructured.Unstructured {
obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(schema.GroupVersionKind{Group: group, Version: "v1", Kind: kind})
obj.SetName(name)
obj.SetNamespace(namespace)
return obj
}
project := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Name: "test-project",
Namespace: "cd",
},
Spec: v1alpha1.AppProjectSpec{
Destinations: []v1alpha1.ApplicationDestination{
{Namespace: "default", Server: "*"},
},
},
}
destCluster := &v1alpha1.Cluster{
Server: "https://kubernetes.default.svc",
}
noopGetClusters := func(_ string) ([]*v1alpha1.Cluster, error) {
return nil, nil
}
t.Run("nil APIResource returns error", func(t *testing.T) {
t.Parallel()
un := newResource("apps", "Deployment", "my-deploy", "default")
err := validateSyncPermissions(project, destCluster, noopGetClusters, un, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to get API resource info for apps/Deployment")
assert.Contains(t, err.Error(), "unable to verify permissions")
})
t.Run("permitted namespaced resource returns no error", func(t *testing.T) {
t.Parallel()
un := newResource("", "ConfigMap", "my-cm", "default")
res := &metav1.APIResource{Name: "configmaps", Namespaced: true}
err := validateSyncPermissions(project, destCluster, noopGetClusters, un, res)
assert.NoError(t, err)
})
t.Run("group kind not permitted returns error", func(t *testing.T) {
t.Parallel()
projectWithDenyList := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Name: "restricted-project",
Namespace: "cd",
},
Spec: v1alpha1.AppProjectSpec{
Destinations: []v1alpha1.ApplicationDestination{
{Namespace: "*", Server: "*"},
},
ClusterResourceBlacklist: []v1alpha1.ClusterResourceRestrictionItem{
{Group: "rbac.authorization.k8s.io", Kind: "ClusterRole"},
},
},
}
un := newResource("rbac.authorization.k8s.io", "ClusterRole", "my-role", "")
res := &metav1.APIResource{Name: "clusterroles", Namespaced: false}
err := validateSyncPermissions(projectWithDenyList, destCluster, noopGetClusters, un, res)
require.Error(t, err)
assert.Contains(t, err.Error(), "is not permitted in project")
})
t.Run("namespace not permitted returns error", func(t *testing.T) {
t.Parallel()
un := newResource("", "ConfigMap", "my-cm", "kube-system")
res := &metav1.APIResource{Name: "configmaps", Namespaced: true}
err := validateSyncPermissions(project, destCluster, noopGetClusters, un, res)
require.Error(t, err)
assert.Contains(t, err.Error(), "namespace kube-system is not permitted in project")
})
t.Run("cluster-scoped resource skips namespace check", func(t *testing.T) {
t.Parallel()
projectWithClusterResources := &v1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{
Name: "test-project",
Namespace: "cd",
},
Spec: v1alpha1.AppProjectSpec{
Destinations: []v1alpha1.ApplicationDestination{
{Namespace: "default", Server: "*"},
},
ClusterResourceWhitelist: []v1alpha1.ClusterResourceRestrictionItem{
{Group: "*", Kind: "*"},
},
},
}
un := newResource("", "Namespace", "my-ns", "")
res := &metav1.APIResource{Name: "namespaces", Namespaced: false}
err := validateSyncPermissions(projectWithClusterResources, destCluster, noopGetClusters, un, res)
assert.NoError(t, err)
})
}