feat: add ApplicationSet listResourceEvents API (#25537)

Signed-off-by: Peter Jiang <peterjiang823@gmail.com>
Co-authored-by: Alexy Mantha <alexy@mantha.dev>
This commit is contained in:
Peter Jiang
2025-12-19 01:04:26 -08:00
committed by GitHub
parent 6f21978637
commit 5859065650
7 changed files with 466 additions and 73 deletions

View File

@@ -18,6 +18,7 @@ import (
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
@@ -113,21 +114,7 @@ func NewServer(
func (s *Server) Get(ctx context.Context, q *applicationset.ApplicationSetGetQuery) (*v1alpha1.ApplicationSet, error) {
namespace := s.appsetNamespaceOrDefault(q.AppsetNamespace)
if !s.isNamespaceEnabled(namespace) {
return nil, security.NamespaceNotPermittedError(namespace)
}
a, err := s.appsetLister.ApplicationSets(namespace).Get(q.Name)
if err != nil {
return nil, fmt.Errorf("error getting ApplicationSet: %w", err)
}
err = s.enf.EnforceErr(ctx.Value("claims"), rbac.ResourceApplicationSets, rbac.ActionGet, a.RBACName(s.ns))
if err != nil {
return nil, err
}
return a, nil
return s.getAppSetEnforceRBAC(ctx, rbac.ActionGet, namespace, q.Name)
}
// List returns list of ApplicationSets
@@ -341,20 +328,12 @@ func (s *Server) Delete(ctx context.Context, q *applicationset.ApplicationSetDel
func (s *Server) ResourceTree(ctx context.Context, q *applicationset.ApplicationSetTreeQuery) (*v1alpha1.ApplicationSetTree, error) {
namespace := s.appsetNamespaceOrDefault(q.AppsetNamespace)
if !s.isNamespaceEnabled(namespace) {
return nil, security.NamespaceNotPermittedError(namespace)
}
a, err := s.appclientset.ArgoprojV1alpha1().ApplicationSets(namespace).Get(ctx, q.Name, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("error getting ApplicationSet: %w", err)
}
err = s.enf.EnforceErr(ctx.Value("claims"), rbac.ResourceApplicationSets, rbac.ActionGet, a.RBACName(s.ns))
appset, err := s.getAppSetEnforceRBAC(ctx, rbac.ActionGet, namespace, q.Name)
if err != nil {
return nil, err
}
return s.buildApplicationSetTree(a)
return s.buildApplicationSetTree(appset)
}
func (s *Server) Generate(ctx context.Context, q *applicationset.ApplicationSetGenerateRequest) (*applicationset.ApplicationSetGenerateResponse, error) {
@@ -518,3 +497,50 @@ func (s *Server) appsetNamespaceOrDefault(appNs string) string {
func (s *Server) isNamespaceEnabled(namespace string) bool {
return security.IsNamespaceEnabled(namespace, s.ns, s.enabledNamespaces)
}
// getAppSetEnforceRBAC gets the ApplicationSet with the given name in the given namespace and
// verifies that the user has the specified RBAC action permission on it.
//
// Note: Unlike Applications, ApplicationSets are not currently scoped to Projects for RBAC purposes.
// The RBAC name is derived from the template's project field, but there is no project-level isolation
// or validation (e.g., verifying the AppSet belongs to the claimed project)
func (s *Server) getAppSetEnforceRBAC(ctx context.Context, action, namespace, name string) (*v1alpha1.ApplicationSet, error) {
if !s.isNamespaceEnabled(namespace) {
return nil, security.NamespaceNotPermittedError(namespace)
}
appset, err := s.appsetLister.ApplicationSets(namespace).Get(name)
if err != nil {
return nil, fmt.Errorf("error getting ApplicationSet: %w", err)
}
if err := s.enf.EnforceErr(ctx.Value("claims"), rbac.ResourceApplicationSets, action, appset.RBACName(s.ns)); err != nil {
return nil, err
}
return appset, nil
}
// ListResourceEvents returns a list of event resources for an applicationset
func (s *Server) ListResourceEvents(ctx context.Context, q *applicationset.ApplicationSetGetQuery) (*corev1.EventList, error) {
namespace := s.appsetNamespaceOrDefault(q.AppsetNamespace)
appset, err := s.getAppSetEnforceRBAC(ctx, rbac.ActionGet, namespace, q.Name)
if err != nil {
return nil, err
}
fieldSelector := fields.SelectorFromSet(map[string]string{
"involvedObject.name": appset.Name,
"involvedObject.uid": string(appset.UID),
"involvedObject.namespace": appset.Namespace,
}).String()
log.Debugf("Querying for resource events with field selector: %s", fieldSelector)
opts := metav1.ListOptions{FieldSelector: fieldSelector}
list, err := s.k8sClient.CoreV1().Events(namespace).List(ctx, opts)
if err != nil {
return nil, fmt.Errorf("error listing resource events: %w", err)
}
return list.DeepCopy(), nil
}

View File

@@ -8,6 +8,7 @@ option go_package = "github.com/argoproj/argo-cd/v3/pkg/apiclient/applicationset
package applicationset;
import "google/api/annotations.proto";
import "k8s.io/api/core/v1/generated.proto";
import "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1/generated.proto";
// ApplicationSetGetQuery is a query for applicationset resources
@@ -102,4 +103,9 @@ service ApplicationSetService {
option (google.api.http).get = "/api/v1/applicationsets/{name}/resource-tree";
}
// ListResourceEvents returns a list of event resources
rpc ListResourceEvents(ApplicationSetGetQuery) returns (k8s.io.api.core.v1.EventList) {
option (google.api.http).get = "/api/v1/applicationsets/{name}/events";
}
}

View File

@@ -15,6 +15,7 @@ import (
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/fake"
k8scache "k8s.io/client-go/tools/cache"
@@ -52,8 +53,16 @@ func fakeCluster() *appsv1.Cluster {
}
}
// return an ApplicationServiceServer which returns fake data
// newTestAppSetServer returns an ApplicationSetServer with fake data for testing
func newTestAppSetServer(t *testing.T, objects ...client.Object) *Server {
t.Helper()
server, _ := newTestAppSetServerWithK8sClient(t, objects...)
return server
}
// newTestAppSetServerWithK8sClient returns an ApplicationSetServer and the kubernetes clientset for testing.
// Use this variant when you need to create kubernetes resources (e.g., Events) after server creation.
func newTestAppSetServerWithK8sClient(t *testing.T, objects ...client.Object) (*Server, kubernetes.Interface) {
t.Helper()
f := func(enf *rbac.Enforcer) {
_ = enf.SetBuiltinPolicy(assets.BuiltinPolicyCSV)
@@ -63,7 +72,7 @@ func newTestAppSetServer(t *testing.T, objects ...client.Object) *Server {
return newTestAppSetServerWithEnforcerConfigure(t, f, scopedNamespaces, objects...)
}
// return an ApplicationServiceServer which returns fake data
// newTestNamespacedAppSetServer returns an ApplicationSetServer with fake data for testing with namespaced scope
func newTestNamespacedAppSetServer(t *testing.T, objects ...client.Object) *Server {
t.Helper()
f := func(enf *rbac.Enforcer) {
@@ -71,10 +80,11 @@ func newTestNamespacedAppSetServer(t *testing.T, objects ...client.Object) *Serv
enf.SetDefaultRole("role:admin")
}
scopedNamespaces := "argocd"
return newTestAppSetServerWithEnforcerConfigure(t, f, scopedNamespaces, objects...)
server, _ := newTestAppSetServerWithEnforcerConfigure(t, f, scopedNamespaces, objects...)
return server
}
func newTestAppSetServerWithEnforcerConfigure(t *testing.T, f func(*rbac.Enforcer), namespace string, objects ...client.Object) *Server {
func newTestAppSetServerWithEnforcerConfigure(t *testing.T, f func(*rbac.Enforcer), namespace string, objects ...client.Object) (*Server, kubernetes.Interface) {
t.Helper()
kubeclientset := fake.NewClientset(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
@@ -179,7 +189,7 @@ func newTestAppSetServerWithEnforcerConfigure(t *testing.T, f func(*rbac.Enforce
true,
testEnableEventList,
)
return server.(*Server)
return server.(*Server), kubeclientset
}
func newTestAppSet(opts ...func(appset *appsv1.ApplicationSet)) *appsv1.ApplicationSet {
@@ -705,6 +715,106 @@ func TestResourceTree(t *testing.T) {
})
}
func TestListResourceEvents(t *testing.T) {
appSet1 := newTestAppSet(func(appset *appsv1.ApplicationSet) {
appset.Name = "AppSet1"
appset.UID = "test-uid-123"
})
appSet2 := newTestAppSet(func(appset *appsv1.ApplicationSet) {
appset.Name = "AppSet2"
})
t.Run("ListResourceEvents in default namespace", func(t *testing.T) {
appSetServer := newTestAppSetServer(t, appSet1, appSet2)
appsetQuery := applicationset.ApplicationSetGetQuery{Name: "AppSet1"}
res, err := appSetServer.ListResourceEvents(t.Context(), &appsetQuery)
require.NoError(t, err)
// No events exist in the fake client, so we expect an empty list
assert.Empty(t, res.Items)
})
t.Run("ListResourceEvents in named namespace", func(t *testing.T) {
appSetServer := newTestAppSetServer(t, appSet1, appSet2)
appsetQuery := applicationset.ApplicationSetGetQuery{Name: "AppSet1", AppsetNamespace: testNamespace}
res, err := appSetServer.ListResourceEvents(t.Context(), &appsetQuery)
require.NoError(t, err)
assert.Empty(t, res.Items)
})
t.Run("ListResourceEvents in not allowed namespace", func(t *testing.T) {
appSetServer := newTestAppSetServer(t, appSet1, appSet2)
appsetQuery := applicationset.ApplicationSetGetQuery{Name: "AppSet1", AppsetNamespace: "NOT-ALLOWED"}
_, err := appSetServer.ListResourceEvents(t.Context(), &appsetQuery)
assert.EqualError(t, err, "namespace 'NOT-ALLOWED' is not permitted")
})
t.Run("ListResourceEvents for non-existent appset", func(t *testing.T) {
appSetServer := newTestAppSetServer(t, appSet1, appSet2)
appsetQuery := applicationset.ApplicationSetGetQuery{Name: "DoesNotExist"}
_, err := appSetServer.ListResourceEvents(t.Context(), &appsetQuery)
require.Error(t, err)
assert.Contains(t, err.Error(), "error getting ApplicationSet")
})
t.Run("ListResourceEvents with events returns non-empty list", func(t *testing.T) {
appSetServer, kubeclientset := newTestAppSetServerWithK8sClient(t, appSet1, appSet2)
// Create events after server creation using the kubeclientset
_, err := kubeclientset.CoreV1().Events(testNamespace).Create(t.Context(), &corev1.Event{
ObjectMeta: metav1.ObjectMeta{
Name: "appset1-event-1",
Namespace: testNamespace,
},
InvolvedObject: corev1.ObjectReference{
Name: "AppSet1",
Namespace: testNamespace,
UID: "test-uid-123",
},
Reason: "Created",
Message: "ApplicationSet created successfully",
Type: corev1.EventTypeNormal,
}, metav1.CreateOptions{})
require.NoError(t, err)
_, err = kubeclientset.CoreV1().Events(testNamespace).Create(t.Context(), &corev1.Event{
ObjectMeta: metav1.ObjectMeta{
Name: "appset1-event-2",
Namespace: testNamespace,
},
InvolvedObject: corev1.ObjectReference{
Name: "AppSet1",
Namespace: testNamespace,
UID: "test-uid-123",
},
Reason: "Updated",
Message: "ApplicationSet updated successfully",
Type: corev1.EventTypeNormal,
}, metav1.CreateOptions{})
require.NoError(t, err)
appsetQuery := applicationset.ApplicationSetGetQuery{Name: "AppSet1"}
res, err := appSetServer.ListResourceEvents(t.Context(), &appsetQuery)
require.NoError(t, err)
assert.NotEmpty(t, res.Items)
assert.Len(t, res.Items, 2)
// Verify the returned events have the expected content
eventNames := []string{res.Items[0].Name, res.Items[1].Name}
assert.Contains(t, eventNames, "appset1-event-1")
assert.Contains(t, eventNames, "appset1-event-2")
})
}
func TestAppSet_Generate_Cluster(t *testing.T) {
appSet1 := newTestAppSet(func(appset *appsv1.ApplicationSet) {
appset.Name = "AppSet1"