diff --git a/gitops-engine/pkg/utils/kube/resource_ops.go b/gitops-engine/pkg/utils/kube/resource_ops.go index ae52a372c7..c5fc805f24 100644 --- a/gitops-engine/pkg/utils/kube/resource_ops.go +++ b/gitops-engine/pkg/utils/kube/resource_ops.go @@ -603,11 +603,24 @@ func (k *kubectlResourceOperations) authReconcile(ctx context.Context, obj *unst if err != nil { return "", fmt.Errorf("error creating kube client: %w", err) } + + clusterScoped := obj.GetKind() == "ClusterRole" || obj.GetKind() == "ClusterRoleBinding" + // `kubectl auth reconcile` has a side effect of auto-creating namespaces if it doesn't exist. // See: https://github.com/kubernetes/kubernetes/issues/71185. This is behavior which we do // not want. We need to check if the namespace exists, before know if it is safe to run this // command. Skip this for dryRuns. - if dryRunStrategy == cmdutil.DryRunNone && obj.GetNamespace() != "" { + + // When an Argo CD Application specifies destination.namespace, that namespace + // may be propagated even for cluster-scoped resources. Passing a namespace in + // this case causes `kubectl auth reconcile` to fail with: + // "namespaces not found" + // or may trigger unintended namespace handling behavior. + // Therefore, we skip namespace existence checks for cluster-scoped RBAC + // resources and allow reconcile to run without a namespace. + // + // https://github.com/argoproj/argo-cd/issues/24833 + if dryRunStrategy == cmdutil.DryRunNone && obj.GetNamespace() != "" && !clusterScoped { _, err = kubeClient.CoreV1().Namespaces().Get(ctx, obj.GetNamespace(), metav1.GetOptions{}) if err != nil { return "", fmt.Errorf("error getting namespace %s: %w", obj.GetNamespace(), err) diff --git a/gitops-engine/pkg/utils/kube/resource_ops_test.go b/gitops-engine/pkg/utils/kube/resource_ops_test.go new file mode 100644 index 0000000000..29fa2899f2 --- /dev/null +++ b/gitops-engine/pkg/utils/kube/resource_ops_test.go @@ -0,0 +1,75 @@ +package kube + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + testingutils "github.com/argoproj/argo-cd/gitops-engine/pkg/utils/testing" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +func TestAuthReconcileWithMissingNamespace(t *testing.T) { + namespace := "test-ns" + fakeBearer := "fake-bearer" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + status := &metav1.Status{ + Status: "Failure", + Message: fmt.Sprintf("namespace \"%s\" not found", namespace), + Reason: metav1.StatusReasonNotFound, + Code: http.StatusNotFound, + } + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(status) + })) + defer server.Close() + + kubeConfigFlags := genericclioptions.NewConfigFlags(true) + kubeConfigFlags.Namespace = &namespace + kubeConfigFlags.APIServer = &server.URL + kubeConfigFlags.BearerToken = &fakeBearer + matchFlags := cmdutil.NewMatchVersionFlags(kubeConfigFlags) + fact := cmdutil.NewFactory(matchFlags) + + config := &rest.Config{Host: server.URL} + k := &kubectlResourceOperations{ + config: config, + fact: fact, + } + + role := testingutils.NewRole() + role.SetNamespace(namespace) + + _, err := k.authReconcile(context.Background(), role, "/dev/null", cmdutil.DryRunNone) + assert.Error(t, err) + assert.True(t, errors.IsNotFound(err), "returned error wasn't not found") + + roleBinding := testingutils.NewRoleBinding() + roleBinding.SetNamespace(namespace) + + _, err = k.authReconcile(context.Background(), roleBinding, "/dev/null", cmdutil.DryRunNone) + assert.Error(t, err) + assert.True(t, errors.IsNotFound(err), "returned error wasn't not found") + + clusterRole := testingutils.NewClusterRole() + clusterRole.SetNamespace(namespace) + + _, err = k.authReconcile(context.Background(), clusterRole, "/dev/null", cmdutil.DryRunNone) + assert.NoError(t, err) + + clusterRoleBinding := testingutils.NewClusterRoleBinding() + clusterRoleBinding.SetNamespace(namespace) + + _, err = k.authReconcile(context.Background(), clusterRoleBinding, "/dev/null", cmdutil.DryRunNone) + assert.NoError(t, err) +} diff --git a/gitops-engine/pkg/utils/testing/testdata.go b/gitops-engine/pkg/utils/testing/testdata.go index 5ea16e8cd1..8fc91be939 100644 --- a/gitops-engine/pkg/utils/testing/testdata.go +++ b/gitops-engine/pkg/utils/testing/testdata.go @@ -97,3 +97,55 @@ metadata: name: testnamespace spec:`) } + +func NewRole() *unstructured.Unstructured { + return Unstructured(`apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: my-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "watch", "list"]`) +} + +func NewRoleBinding() *unstructured.Unstructured { + return Unstructured(`apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: my-role-binding +subjects: +- kind: User + name: user + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: my-role + apiGroup: rbac.authorization.k8s.io`) +} + +func NewClusterRole() *unstructured.Unstructured { + return Unstructured(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: my-cluster-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "watch", "list"]`) +} + +func NewClusterRoleBinding() *unstructured.Unstructured { + return Unstructured(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: my-cluster-role-binding +subjects: +- kind: Group + name: group + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: my-cluster-role + apiGroup: rbac.authorization.k8s.io`) +}