chore(ui): fix Incorrect links to applications managed by other argo instances (#23266)

Signed-off-by: Atif Ali <[email protected]>
This commit is contained in:
Atif Ali
2025-10-14 21:38:14 -04:00
committed by GitHub
parent f78993de4b
commit 249abf9d2b
25 changed files with 1024 additions and 206 deletions
+1
View File
@@ -229,6 +229,7 @@ const (
// AnnotationKeyAppSkipReconcile tells the Application to skip the Application controller reconcile.
// Skip reconcile when the value is "true" or any other string values that can be strconv.ParseBool() to be true.
AnnotationKeyAppSkipReconcile = "argocd.argoproj.io/skip-reconcile"
// LabelKeyComponentRepoServer is the label key to identify the component as repo-server
LabelKeyComponentRepoServer = "app.kubernetes.io/component"
// LabelValueComponentRepoServer is the label value for the repo-server component
+11
View File
@@ -68,6 +68,10 @@ func populateNodeInfo(un *unstructured.Unstructured, res *ResourceInfo, customLa
case "ServiceEntry":
populateIstioServiceEntryInfo(un, res)
}
case "argoproj.io":
if gvk.Kind == "Application" {
populateApplicationInfo(un, res)
}
}
}
@@ -488,6 +492,13 @@ func populateHostNodeInfo(un *unstructured.Unstructured, res *ResourceInfo) {
}
}
func populateApplicationInfo(un *unstructured.Unstructured, res *ResourceInfo) {
// Add managed-by-url annotation to info if present
if managedByURL, ok := un.GetAnnotations()[v1alpha1.AnnotationKeyManagedByURL]; ok {
res.Info = append(res.Info, v1alpha1.InfoItem{Name: "managed-by-url", Value: managedByURL})
}
}
func generateManifestHash(un *unstructured.Unstructured, ignores []v1alpha1.ResourceIgnoreDifferences, overrides map[string]v1alpha1.ResourceOverride, opts normalizers.IgnoreNormalizerOpts) (string, error) {
normalizer, err := normalizers.NewIgnoreNormalizer(ignores, overrides, opts)
if err != nil {
@@ -11,4 +11,6 @@ const (
// absolute path means an absolute path within the repository and the relative path is relative to the application
// source path within the repository.
AnnotationKeyManifestGeneratePaths = "argocd.argoproj.io/manifest-generate-paths"
// AnnotationKeyManagedByURL contains the URL of the Argo CD instance managing the application
AnnotationKeyManagedByURL = "argocd.argoproj.io/managed-by-url"
)
+25 -2
View File
@@ -310,7 +310,13 @@ func (s *Server) List(ctx context.Context, q *application.ApplicationQuery) (*v1
continue
}
if s.enf.Enforce(ctx.Value("claims"), rbac.ResourceApplications, rbac.ActionGet, a.RBACName(s.ns)) {
newItems = append(newItems, *a)
// Create a deep copy to ensure all metadata fields including annotations are preserved
appCopy := a.DeepCopy()
// Explicitly copy annotations in case DeepCopy does not preserve them
if a.Annotations != nil {
appCopy.Annotations = a.Annotations
}
newItems = append(newItems, *appCopy)
}
}
@@ -776,7 +782,7 @@ func (s *Server) Get(ctx context.Context, q *application.ApplicationQuery) (*v1a
if q.Refresh == nil {
s.inferResourcesStatusHealth(a)
return a, nil
return a.DeepCopy(), nil
}
refreshType := v1alpha1.RefreshTypeNormal
@@ -1353,6 +1359,12 @@ func (s *Server) validateAndNormalizeApp(ctx context.Context, app *v1alpha1.Appl
return status.Errorf(codes.InvalidArgument, "application spec for %s is invalid: %s", app.Name, argo.FormatAppConditions(conditions))
}
// Validate managed-by-url annotation
managedByURLConditions := argo.ValidateManagedByURL(app)
if len(managedByURLConditions) > 0 {
return status.Errorf(codes.InvalidArgument, "application spec for %s is invalid: %s", app.Name, argo.FormatAppConditions(managedByURLConditions))
}
app.Spec = *argo.NormalizeApplicationSpec(&app.Spec)
return nil
}
@@ -2260,8 +2272,19 @@ func (s *Server) ListLinks(ctx context.Context, req *application.ListAppLinksReq
return nil, err
}
// Create deep links object with managed-by URL
deepLinksObject := deeplinks.CreateDeepLinksObject(nil, obj, clstObj, nil)
// If no managed-by URL is set, use the current instance's URL
if deepLinksObject[deeplinks.ManagedByURLKey] == nil {
settings, err := s.settingsMgr.GetSettings()
if err != nil {
log.Warnf("Failed to get settings: %v", err)
} else if settings.URL != "" {
deepLinksObject[deeplinks.ManagedByURLKey] = settings.URL
}
}
finalList, errorList := deeplinks.EvaluateDeepLinksResponse(deepLinksObject, obj.GetName(), deepLinks)
if len(errorList) > 0 {
log.Errorf("errorList while evaluating application deep links, %v", strings.Join(errorList, ", "))
+2 -2
View File
@@ -59,7 +59,7 @@ func (s *Server) List(ctx context.Context, q *cluster.ClusterQuery) (*appv1.Clus
filteredItems := clusterList.Items
// Filter clusters by id
if filteredItems, err = filterClustersById(filteredItems, q.Id); err != nil {
if filteredItems, err = filterClustersByID(filteredItems, q.Id); err != nil {
return nil, fmt.Errorf("error filtering clusters by id: %w", err)
}
@@ -89,7 +89,7 @@ func (s *Server) List(ctx context.Context, q *cluster.ClusterQuery) (*appv1.Clus
return &cl, nil
}
func filterClustersById(clusters []appv1.Cluster, id *cluster.ClusterID) ([]appv1.Cluster, error) {
func filterClustersByID(clusters []appv1.Cluster, id *cluster.ClusterID) ([]appv1.Cluster, error) {
if id == nil {
return clusters, nil
}
-1
View File
@@ -80,5 +80,4 @@ service ClusterService {
rpc InvalidateCache(ClusterQuery) returns (github.com.argoproj.argo_cd.v3.pkg.apis.application.v1alpha1.Cluster) {
option (google.api.http).post = "/api/v1/clusters/{id.value}/invalidate-cache";
}
}
+95 -66
View File
@@ -8,25 +8,25 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/argoproj/argo-cd/v3/util/assets"
"github.com/argoproj/gitops-engine/pkg/utils/kube/kubetest"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"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/client-go/kubernetes/fake"
"k8s.io/utils/ptr"
"github.com/argoproj/argo-cd/v3/common"
"github.com/argoproj/argo-cd/v3/pkg/apiclient/cluster"
"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
appv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
servercache "github.com/argoproj/argo-cd/v3/server/cache"
"github.com/argoproj/argo-cd/v3/server/deeplinks"
"github.com/argoproj/argo-cd/v3/test"
"github.com/argoproj/argo-cd/v3/util/assets"
cacheutil "github.com/argoproj/argo-cd/v3/util/cache"
appstatecache "github.com/argoproj/argo-cd/v3/util/cache/appstate"
"github.com/argoproj/argo-cd/v3/util/db"
@@ -139,25 +139,25 @@ func TestUpdateCluster_RejectInvalidParams(t *testing.T) {
}{
{
name: "allowed cluster URL in body, disallowed cluster URL in query",
request: cluster.ClusterUpdateRequest{Cluster: &v1alpha1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "", ClusterResources: true}, Id: &cluster.ClusterID{Type: "", Value: "https://127.0.0.2"}, UpdatedFields: []string{"clusterResources", "project"}},
request: cluster.ClusterUpdateRequest{Cluster: &appv1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "", ClusterResources: true}, Id: &cluster.ClusterID{Type: "", Value: "https://127.0.0.2"}, UpdatedFields: []string{"clusterResources", "project"}},
},
{
name: "allowed cluster URL in body, disallowed cluster name in query",
request: cluster.ClusterUpdateRequest{Cluster: &v1alpha1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "", ClusterResources: true}, Id: &cluster.ClusterID{Type: "name", Value: "disallowed-unscoped"}, UpdatedFields: []string{"clusterResources", "project"}},
request: cluster.ClusterUpdateRequest{Cluster: &appv1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "", ClusterResources: true}, Id: &cluster.ClusterID{Type: "name", Value: "disallowed-unscoped"}, UpdatedFields: []string{"clusterResources", "project"}},
},
{
name: "allowed cluster URL in body, disallowed cluster name in query, changing unscoped to scoped",
request: cluster.ClusterUpdateRequest{Cluster: &v1alpha1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "allowed-project", ClusterResources: true}, Id: &cluster.ClusterID{Type: "", Value: "https://127.0.0.2"}, UpdatedFields: []string{"clusterResources", "project"}},
request: cluster.ClusterUpdateRequest{Cluster: &appv1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "allowed-project", ClusterResources: true}, Id: &cluster.ClusterID{Type: "", Value: "https://127.0.0.2"}, UpdatedFields: []string{"clusterResources", "project"}},
},
{
name: "allowed cluster URL in body, disallowed cluster URL in query, changing unscoped to scoped",
request: cluster.ClusterUpdateRequest{Cluster: &v1alpha1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "allowed-project", ClusterResources: true}, Id: &cluster.ClusterID{Type: "name", Value: "disallowed-unscoped"}, UpdatedFields: []string{"clusterResources", "project"}},
request: cluster.ClusterUpdateRequest{Cluster: &appv1.Cluster{Name: "", Server: "https://127.0.0.1", Project: "allowed-project", ClusterResources: true}, Id: &cluster.ClusterID{Type: "name", Value: "disallowed-unscoped"}, UpdatedFields: []string{"clusterResources", "project"}},
},
}
db := &dbmocks.ArgoDB{}
clusters := []v1alpha1.Cluster{
clusters := []appv1.Cluster{
{
Name: "allowed-unscoped",
Server: "https://127.0.0.1",
@@ -179,8 +179,8 @@ func TestUpdateCluster_RejectInvalidParams(t *testing.T) {
}
db.On("ListClusters", mock.Anything).Return(
func(_ context.Context) *v1alpha1.ClusterList {
return &v1alpha1.ClusterList{
func(_ context.Context) *appv1.ClusterList {
return &appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: clusters,
}
@@ -190,7 +190,7 @@ func TestUpdateCluster_RejectInvalidParams(t *testing.T) {
},
)
db.On("UpdateCluster", mock.Anything, mock.Anything).Return(
func(_ context.Context, c *v1alpha1.Cluster) *v1alpha1.Cluster {
func(_ context.Context, c *appv1.Cluster) *appv1.Cluster {
for _, cluster := range clusters {
if c.Server == cluster.Server {
return c
@@ -198,7 +198,7 @@ func TestUpdateCluster_RejectInvalidParams(t *testing.T) {
}
return nil
},
func(_ context.Context, c *v1alpha1.Cluster) error {
func(_ context.Context, c *appv1.Cluster) error {
for _, cluster := range clusters {
if c.Server == cluster.Server {
return nil
@@ -208,7 +208,7 @@ func TestUpdateCluster_RejectInvalidParams(t *testing.T) {
},
)
db.On("GetCluster", mock.Anything, mock.Anything).Return(
func(_ context.Context, server string) *v1alpha1.Cluster {
func(_ context.Context, server string) *appv1.Cluster {
for _, cluster := range clusters {
if server == cluster.Server {
return &cluster
@@ -246,14 +246,14 @@ p, role:test, clusters, *, allowed-project/*, allow`)
func TestGetCluster_UrlEncodedName(t *testing.T) {
db := &dbmocks.ArgoDB{}
mockCluster := v1alpha1.Cluster{
mockCluster := appv1.Cluster{
Name: "test/ing",
Server: "https://127.0.0.1",
Namespaces: []string{"default", "kube-system"},
}
mockClusterList := v1alpha1.ClusterList{
mockClusterList := appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{
Items: []appv1.Cluster{
mockCluster,
},
}
@@ -276,14 +276,14 @@ func TestGetCluster_UrlEncodedName(t *testing.T) {
func TestGetCluster_NameWithUrlEncodingButShouldNotBeUnescaped(t *testing.T) {
db := &dbmocks.ArgoDB{}
mockCluster := v1alpha1.Cluster{
mockCluster := appv1.Cluster{
Name: "test%2fing",
Server: "https://127.0.0.1",
Namespaces: []string{"default", "kube-system"},
}
mockClusterList := v1alpha1.ClusterList{
mockClusterList := appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{
Items: []appv1.Cluster{
mockCluster,
},
}
@@ -305,12 +305,12 @@ func TestGetCluster_NameWithUrlEncodingButShouldNotBeUnescaped(t *testing.T) {
func TestGetCluster_CannotSetCADataAndInsecureTrue(t *testing.T) {
testNamespace := "default"
localCluster := &v1alpha1.Cluster{
localCluster := &appv1.Cluster{
Name: "my-cluster-name",
Server: "https://my-cluster-server",
Namespaces: []string{testNamespace},
Config: v1alpha1.ClusterConfig{
TLSClientConfig: v1alpha1.TLSClientConfig{
Config: appv1.ClusterConfig{
TLSClientConfig: appv1.TLSClientConfig{
Insecure: true,
CAData: []byte(rootCACert),
CertData: []byte(certData),
@@ -341,9 +341,9 @@ func TestGetCluster_CannotSetCADataAndInsecureTrue(t *testing.T) {
func TestUpdateCluster_NoFieldsPaths(t *testing.T) {
db := &dbmocks.ArgoDB{}
var updated *v1alpha1.Cluster
var updated *appv1.Cluster
clusters := []v1alpha1.Cluster{
clusters := []appv1.Cluster{
{
Name: "minikube",
Server: "https://127.0.0.1",
@@ -351,21 +351,21 @@ func TestUpdateCluster_NoFieldsPaths(t *testing.T) {
},
}
clusterList := v1alpha1.ClusterList{
clusterList := appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: clusters,
}
db.On("ListClusters", mock.Anything).Return(&clusterList, nil)
db.On("UpdateCluster", mock.Anything, mock.MatchedBy(func(c *v1alpha1.Cluster) bool {
db.On("UpdateCluster", mock.Anything, mock.MatchedBy(func(c *appv1.Cluster) bool {
updated = c
return true
})).Return(&v1alpha1.Cluster{}, nil)
})).Return(&appv1.Cluster{}, nil)
server := NewServer(db, newNoopEnforcer(), newServerInMemoryCache(), &kubetest.MockKubectlCmd{})
_, err := server.Update(t.Context(), &cluster.ClusterUpdateRequest{
Cluster: &v1alpha1.Cluster{
Cluster: &appv1.Cluster{
Name: "minikube",
Namespaces: []string{"default", "kube-system"},
},
@@ -379,21 +379,21 @@ func TestUpdateCluster_NoFieldsPaths(t *testing.T) {
func TestUpdateCluster_FieldsPathSet(t *testing.T) {
db := &dbmocks.ArgoDB{}
var updated *v1alpha1.Cluster
db.On("GetCluster", mock.Anything, "https://127.0.0.1").Return(&v1alpha1.Cluster{
var updated *appv1.Cluster
db.On("GetCluster", mock.Anything, "https://127.0.0.1").Return(&appv1.Cluster{
Name: "minikube",
Server: "https://127.0.0.1",
Namespaces: []string{"default", "kube-system"},
}, nil)
db.On("UpdateCluster", mock.Anything, mock.MatchedBy(func(c *v1alpha1.Cluster) bool {
db.On("UpdateCluster", mock.Anything, mock.MatchedBy(func(c *appv1.Cluster) bool {
updated = c
return true
})).Return(&v1alpha1.Cluster{}, nil)
})).Return(&appv1.Cluster{}, nil)
server := NewServer(db, newNoopEnforcer(), newServerInMemoryCache(), &kubetest.MockKubectlCmd{})
_, err := server.Update(t.Context(), &cluster.ClusterUpdateRequest{
Cluster: &v1alpha1.Cluster{
Cluster: &appv1.Cluster{
Server: "https://127.0.0.1",
Shard: ptr.To(int64(1)),
},
@@ -410,7 +410,7 @@ func TestUpdateCluster_FieldsPathSet(t *testing.T) {
"env": "qa",
}
_, err = server.Update(t.Context(), &cluster.ClusterUpdateRequest{
Cluster: &v1alpha1.Cluster{
Cluster: &appv1.Cluster{
Server: "https://127.0.0.1",
Labels: labelEnv,
},
@@ -427,7 +427,7 @@ func TestUpdateCluster_FieldsPathSet(t *testing.T) {
"env": "qa",
}
_, err = server.Update(t.Context(), &cluster.ClusterUpdateRequest{
Cluster: &v1alpha1.Cluster{
Cluster: &appv1.Cluster{
Server: "https://127.0.0.1",
Annotations: annotationEnv,
},
@@ -441,7 +441,7 @@ func TestUpdateCluster_FieldsPathSet(t *testing.T) {
assert.Equal(t, updated.Annotations, annotationEnv)
_, err = server.Update(t.Context(), &cluster.ClusterUpdateRequest{
Cluster: &v1alpha1.Cluster{
Cluster: &appv1.Cluster{
Server: "https://127.0.0.1",
Project: "new-project",
},
@@ -499,7 +499,7 @@ func TestDeleteClusterByName(t *testing.T) {
func TestRotateAuth(t *testing.T) {
testNamespace := "kube-system"
token := "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJrdWJlLXN5c3RlbSIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VjcmV0Lm5hbWUiOiJhcmdvY2QtbWFuYWdlci10b2tlbi10ajc5ciIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50Lm5hbWUiOiJhcmdvY2QtbWFuYWdlciIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50LnVpZCI6IjkxZGQzN2NmLThkOTItMTFlOS1hMDkxLWQ2NWYyYWU3ZmE4ZCIsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDprdWJlLXN5c3RlbTphcmdvY2QtbWFuYWdlciJ9.ytZjt2pDV8-A7DBMR06zQ3wt9cuVEfq262TQw7sdra-KRpDpMPnziMhc8bkwvgW-LGhTWUh5iu1y-1QhEx6mtbCt7vQArlBRxfvM5ys6ClFkplzq5c2TtZ7EzGSD0Up7tdxuG9dvR6TGXYdfFcG779yCdZo2H48sz5OSJfdEriduMEY1iL5suZd3ebOoVi1fGflmqFEkZX6SvxkoArl5mtNP6TvZ1eTcn64xh4ws152hxio42E-eSnl_CET4tpB5vgP5BVlSKW2xB7w2GJxqdETA5LJRI_OilY77dTOp8cMr_Ck3EOeda3zHfh4Okflg8rZFEeAuJYahQNeAILLkcA"
config := v1alpha1.ClusterConfig{
config := appv1.ClusterConfig{
BearerToken: token,
}
@@ -611,25 +611,25 @@ func TestListCluster(t *testing.T) {
db := &dbmocks.ArgoDB{}
fooCluster := v1alpha1.Cluster{
fooCluster := appv1.Cluster{
Name: "foo",
Server: "https://127.0.0.1",
Namespaces: []string{"default", "kube-system"},
}
barCluster := v1alpha1.Cluster{
barCluster := appv1.Cluster{
Name: "bar",
Server: "https://192.168.0.1",
Namespaces: []string{"default", "kube-system"},
}
bazCluster := v1alpha1.Cluster{
bazCluster := appv1.Cluster{
Name: "test/ing",
Server: "https://testing.com",
Namespaces: []string{"default", "kube-system"},
}
mockClusterList := v1alpha1.ClusterList{
mockClusterList := appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{fooCluster, barCluster, bazCluster},
Items: []appv1.Cluster{fooCluster, barCluster, bazCluster},
}
db.On("ListClusters", mock.Anything).Return(&mockClusterList, nil)
@@ -639,7 +639,7 @@ func TestListCluster(t *testing.T) {
tests := []struct {
name string
q *cluster.ClusterQuery
want *v1alpha1.ClusterList
want *appv1.ClusterList
wantErr bool
}{
{
@@ -647,9 +647,9 @@ func TestListCluster(t *testing.T) {
q: &cluster.ClusterQuery{
Name: fooCluster.Name,
},
want: &v1alpha1.ClusterList{
want: &appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{fooCluster},
Items: []appv1.Cluster{fooCluster},
},
},
{
@@ -657,9 +657,9 @@ func TestListCluster(t *testing.T) {
q: &cluster.ClusterQuery{
Server: barCluster.Server,
},
want: &v1alpha1.ClusterList{
want: &appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{barCluster},
Items: []appv1.Cluster{barCluster},
},
},
{
@@ -670,9 +670,9 @@ func TestListCluster(t *testing.T) {
Value: fooCluster.Name,
},
},
want: &v1alpha1.ClusterList{
want: &appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{fooCluster},
Items: []appv1.Cluster{fooCluster},
},
},
{
@@ -683,9 +683,9 @@ func TestListCluster(t *testing.T) {
Value: "test%2fing",
},
},
want: &v1alpha1.ClusterList{
want: &appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{bazCluster},
Items: []appv1.Cluster{bazCluster},
},
},
{
@@ -696,9 +696,9 @@ func TestListCluster(t *testing.T) {
Value: barCluster.Server,
},
},
want: &v1alpha1.ClusterList{
want: &appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{barCluster},
Items: []appv1.Cluster{barCluster},
},
},
}
@@ -723,14 +723,14 @@ func TestGetClusterAndVerifyAccess(t *testing.T) {
t.Run("GetClusterAndVerifyAccess - No Cluster", func(t *testing.T) {
db := &dbmocks.ArgoDB{}
mockCluster := v1alpha1.Cluster{
mockCluster := appv1.Cluster{
Name: "test/ing",
Server: "https://127.0.0.1",
Namespaces: []string{"default", "kube-system"},
}
mockClusterList := v1alpha1.ClusterList{
mockClusterList := appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{
Items: []appv1.Cluster{
mockCluster,
},
}
@@ -749,14 +749,14 @@ func TestGetClusterAndVerifyAccess(t *testing.T) {
t.Run("GetClusterAndVerifyAccess - Permissions Denied", func(t *testing.T) {
db := &dbmocks.ArgoDB{}
mockCluster := v1alpha1.Cluster{
mockCluster := appv1.Cluster{
Name: "test/ing",
Server: "https://127.0.0.1",
Namespaces: []string{"default", "kube-system"},
}
mockClusterList := v1alpha1.ClusterList{
mockClusterList := appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{
Items: []appv1.Cluster{
mockCluster,
},
}
@@ -776,14 +776,14 @@ func TestGetClusterAndVerifyAccess(t *testing.T) {
func TestNoClusterEnumeration(t *testing.T) {
db := &dbmocks.ArgoDB{}
mockCluster := v1alpha1.Cluster{
mockCluster := appv1.Cluster{
Name: "test/ing",
Server: "https://127.0.0.1",
Namespaces: []string{"default", "kube-system"},
}
mockClusterList := v1alpha1.ClusterList{
mockClusterList := appv1.ClusterList{
ListMeta: metav1.ListMeta{},
Items: []v1alpha1.Cluster{
Items: []appv1.Cluster{
mockCluster,
},
}
@@ -807,14 +807,14 @@ func TestNoClusterEnumeration(t *testing.T) {
t.Run("Update", func(t *testing.T) {
_, err := server.Update(t.Context(), &cluster.ClusterUpdateRequest{
Cluster: &v1alpha1.Cluster{
Cluster: &appv1.Cluster{
Name: "cluster-not-exists",
},
})
require.ErrorIs(t, err, common.PermissionDeniedAPIError, "error message must be _only_ the permission error, to avoid leaking information about cluster existence")
_, err = server.Update(t.Context(), &cluster.ClusterUpdateRequest{
Cluster: &v1alpha1.Cluster{
Cluster: &appv1.Cluster{
Name: "test/ing",
},
})
@@ -857,3 +857,32 @@ func TestNoClusterEnumeration(t *testing.T) {
assert.ErrorIs(t, err, common.PermissionDeniedAPIError, "error message must be _only_ the permission error, to avoid leaking information about cluster existence")
})
}
func TestCreateDeepLinksObject_ManagedByURL(t *testing.T) {
t.Run("includes managed-by-url if annotation exists", func(t *testing.T) {
app := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{
"annotations": map[string]any{
appv1.AnnotationKeyManagedByURL: "https://example.com/argo",
},
},
},
}
result := deeplinks.CreateDeepLinksObject(nil, app, nil, nil)
require.Equal(t, "https://example.com/argo", result[deeplinks.ManagedByURLKey])
})
t.Run("omits managed-by-url if annotation missing", func(t *testing.T) {
app := &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{},
},
}
result := deeplinks.CreateDeepLinksObject(nil, app, nil, nil)
_, exists := result[deeplinks.ManagedByURLKey]
require.False(t, exists)
})
}
+12
View File
@@ -31,6 +31,7 @@ const (
AppDeepLinkShortKey = "app"
ClusterDeepLinkKey = "cluster"
ProjectDeepLinkKey = "project"
ManagedByURLKey = "managedByURL"
)
type ClusterLinksData struct {
@@ -70,6 +71,17 @@ func CreateDeepLinksObject(resourceObj *unstructured.Unstructured, app *unstruct
if app != nil {
deeplinkObj[AppDeepLinkKey] = app.Object
deeplinkObj[AppDeepLinkShortKey] = app.Object
// Add managed-by URL if present in annotations
if app.Object["metadata"] != nil {
if metadata, ok := app.Object["metadata"].(map[string]any); ok {
if annotations, ok := metadata["annotations"].(map[string]any); ok {
if managedByURL, ok := annotations[v1alpha1.AnnotationKeyManagedByURL].(string); ok {
deeplinkObj[ManagedByURLKey] = managedByURL
}
}
}
}
}
if cluster != nil {
deeplinkObj[ClusterDeepLinkKey] = cluster.Object
+102
View File
@@ -11,6 +11,7 @@ import (
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/utils/ptr"
"github.com/argoproj/argo-cd/v3/pkg/apiclient/application"
@@ -209,3 +210,104 @@ func TestDeepLinks(t *testing.T) {
})
}
}
// TestManagedByURLAnnotation tests the managed-by-url annotation logic
func TestManagedByURLAnnotation(t *testing.T) {
t.Run("application with managed-by-url annotation", func(t *testing.T) {
managedByURL := "https://argocd-instance-b.example.com"
// Create an application with managed-by-url annotation
app := &v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Annotations: map[string]string{
v1alpha1.AnnotationKeyManagedByURL: managedByURL,
},
},
}
// Convert to unstructured for the deeplinks function
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app)
require.NoError(t, err)
unstructuredObj := &unstructured.Unstructured{Object: obj}
// Test the deeplinks logic
deeplinksObj := CreateDeepLinksObject(nil, unstructuredObj, nil, nil)
// Verify that the managed-by-url is included
assert.Equal(t, managedByURL, deeplinksObj[ManagedByURLKey])
})
t.Run("application without managed-by-url annotation", func(t *testing.T) {
// Create an application without managed-by-url annotation
app := &v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
},
}
// Convert to unstructured for the deeplinks function
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app)
require.NoError(t, err)
unstructuredObj := &unstructured.Unstructured{Object: obj}
// Test the deeplinks logic
deeplinksObj := CreateDeepLinksObject(nil, unstructuredObj, nil, nil)
// Verify that no managed-by-url is set
assert.Empty(t, deeplinksObj[ManagedByURLKey])
})
t.Run("application with empty managed-by-url annotation", func(t *testing.T) {
// Create an application with empty managed-by-url annotation
app := &v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Annotations: map[string]string{
v1alpha1.AnnotationKeyManagedByURL: "",
},
},
}
// Convert to unstructured for the deeplinks function
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app)
require.NoError(t, err)
unstructuredObj := &unstructured.Unstructured{Object: obj}
// Test the deeplinks logic
deeplinksObj := CreateDeepLinksObject(nil, unstructuredObj, nil, nil)
// Verify that empty managed-by-url is set
assert.Empty(t, deeplinksObj[ManagedByURLKey])
})
t.Run("application with managed-by-url and other annotations", func(t *testing.T) {
managedByURL := "https://argocd-instance-b.example.com"
// Create an application with managed-by-url and other annotations
app := &v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
Annotations: map[string]string{
v1alpha1.AnnotationKeyManagedByURL: managedByURL,
"argocd.argoproj.io/deep-link-1": "https://grafana.example.com/d/argo/argo-cd-application-dashboard",
"argocd.argoproj.io/deep-link-2": "https://kibana.example.com/app/kibana#/discover",
},
},
}
// Convert to unstructured for the deeplinks function
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app)
require.NoError(t, err)
unstructuredObj := &unstructured.Unstructured{Object: obj}
// Test the deeplinks logic
deeplinksObj := CreateDeepLinksObject(nil, unstructuredObj, nil, nil)
// Verify that managed-by-url is included
assert.Equal(t, managedByURL, deeplinksObj[ManagedByURLKey])
// Note: Other deep link annotations are not processed by CreateDeepLinksObject
// They are processed separately by the EvaluateDeepLinksResponse function
})
}
+207
View File
@@ -0,0 +1,207 @@
package e2e
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"github.com/argoproj/argo-cd/v3/pkg/apiclient/application"
"github.com/argoproj/argo-cd/v3/pkg/apiclient/settings"
. "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/v3/test/e2e/fixture"
. "github.com/argoproj/argo-cd/v3/test/e2e/fixture/app"
)
const managedByURLTestPath = "guestbook"
// TestManagedByURLWithAnnotation tests that applications with managed-by-url annotation
// include the managed-by-url in their deep links
func TestManagedByURLWithAnnotation(t *testing.T) {
managedByURL := "https://argocd-instance-b.example.com"
Given(t).
Project("default").
Path(managedByURLTestPath).
When().
CreateApp().
And(func() {
// Add managed-by-url annotation to the application with retry logic
for i := 0; i < 3; i++ {
appObj, err := fixture.AppClientset.ArgoprojV1alpha1().Applications(fixture.ArgoCDNamespace).Get(t.Context(), fixture.Name(), metav1.GetOptions{})
require.NoError(t, err)
if appObj.Annotations == nil {
appObj.Annotations = make(map[string]string)
}
appObj.Annotations[AnnotationKeyManagedByURL] = managedByURL
_, err = fixture.AppClientset.ArgoprojV1alpha1().Applications(fixture.ArgoCDNamespace).Update(t.Context(), appObj, metav1.UpdateOptions{})
if err == nil {
break
}
if i == 2 {
require.NoError(t, err)
}
time.Sleep(100 * time.Millisecond)
}
}).
And(func() {
// Configure deep links that use the managed-by-url template variable
deepLinksConfig := `- url: "{{.managedByURL}}/applications/{{.app.metadata.name}}"
title: "Managed By Instance"
description: "Open in managing ArgoCD instance"`
// Update the argocd-cm configmap to include our test deep links and URL
configMap, err := fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Get(t.Context(), "argocd-cm", metav1.GetOptions{})
require.NoError(t, err)
if configMap.Data == nil {
configMap.Data = make(map[string]string)
}
configMap.Data["application.links"] = deepLinksConfig
configMap.Data["url"] = "https://argocd-test.example.com"
_, err = fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Update(t.Context(), configMap, metav1.UpdateOptions{})
require.NoError(t, err)
// Wait a moment for the config to be picked up
time.Sleep(2 * time.Second)
}).
Then().
And(func(app *Application) {
// Test that the managed-by-url annotation is preserved
assert.Equal(t, managedByURL, app.Annotations[AnnotationKeyManagedByURL])
// Test that the application links include the managed-by-url in the deep links
conn, appClient, err := fixture.ArgoCDClientset.NewApplicationClient()
require.NoError(t, err)
defer conn.Close()
links, err := appClient.ListLinks(t.Context(), &application.ListAppLinksRequest{
Name: ptr.To(app.Name),
})
require.NoError(t, err)
// Verify that deep links are generated with the managed-by-url
assert.NotNil(t, links)
assert.Len(t, links.Items, 1, "Should have 1 deep link configured")
// Check that the link uses the managed-by-url
expectedLink := managedByURL + "/applications/" + app.Name
found := false
for _, link := range links.Items {
if link.Url != nil && *link.Url == expectedLink {
found = true
assert.Equal(t, "Managed By Instance", *link.Title)
assert.Equal(t, "Open in managing ArgoCD instance", *link.Description)
break
}
}
assert.True(t, found, "Deep link should use managed-by-url: %s", expectedLink)
}).
And(func(_ *Application) {
// Clean up: remove the test deep links configuration
configMap, err := fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Get(t.Context(), "argocd-cm", metav1.GetOptions{})
if err == nil && configMap.Data != nil {
delete(configMap.Data, "application.links")
delete(configMap.Data, "url")
_, err := fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Update(t.Context(), configMap, metav1.UpdateOptions{})
if err != nil {
t.Logf("Failed to clean up configmap: %v", err)
}
}
})
}
// TestManagedByURLFallbackToCurrentInstance tests that when no managed-by-url is set,
// the current instance URL is used as fallback
func TestManagedByURLFallbackToCurrentInstance(t *testing.T) {
Given(t).
Project("default").
Path(managedByURLTestPath).
When().
CreateApp().
And(func() {
// Configure deep links that use the managed-by-url template variable
deepLinksConfig := `- url: "{{.managedByURL}}/applications/{{.app.metadata.name}}"
title: "Managed By Instance"
description: "Open in managing ArgoCD instance"`
// Update the argocd-cm configmap to include our test deep links and URL
configMap, err := fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Get(t.Context(), "argocd-cm", metav1.GetOptions{})
require.NoError(t, err)
if configMap.Data == nil {
configMap.Data = make(map[string]string)
}
configMap.Data["application.links"] = deepLinksConfig
configMap.Data["url"] = "https://argocd-test.example.com"
_, err = fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Update(t.Context(), configMap, metav1.UpdateOptions{})
require.NoError(t, err)
// Wait a moment for the config to be picked up
time.Sleep(2 * time.Second)
}).
Then().
And(func(app *Application) {
// Test that the application links use the current instance URL as fallback
conn, appClient, err := fixture.ArgoCDClientset.NewApplicationClient()
require.NoError(t, err)
defer conn.Close()
links, err := appClient.ListLinks(t.Context(), &application.ListAppLinksRequest{
Name: ptr.To(app.Name),
})
require.NoError(t, err)
// Verify that deep links are generated with the current instance URL as fallback
assert.NotNil(t, links)
assert.Len(t, links.Items, 1, "Should have 1 deep link configured")
// Get the current ArgoCD server URL from settings
conn2, settingsClient, err := fixture.ArgoCDClientset.NewSettingsClient()
require.NoError(t, err)
defer conn2.Close()
settings, err := settingsClient.Get(t.Context(), &settings.SettingsQuery{})
require.NoError(t, err)
// Check that the link uses the current instance URL as fallback
expectedLink := settings.URL + "/applications/" + app.Name
found := false
for _, link := range links.Items {
if link.Url != nil && *link.Url == expectedLink {
found = true
assert.Equal(t, "Managed By Instance", *link.Title)
assert.Equal(t, "Open in managing ArgoCD instance", *link.Description)
break
}
}
if !found {
t.Logf("Returned links:")
for _, link := range links.Items {
if link.Url != nil {
t.Logf("- %s", *link.Url)
}
}
}
assert.True(t, found, "Deep link should use current instance URL as fallback: %s", expectedLink)
}).
And(func(_ *Application) {
// Clean up: remove the test deep links configuration
configMap, err := fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Get(t.Context(), "argocd-cm", metav1.GetOptions{})
if err == nil && configMap.Data != nil {
delete(configMap.Data, "application.links")
delete(configMap.Data, "url")
_, err := fixture.KubeClientset.CoreV1().ConfigMaps(fixture.ArgoCDNamespace).Update(t.Context(), configMap, metav1.UpdateOptions{})
if err != nil {
t.Logf("Failed to clean up configmap: %v", err)
}
}
})
}
+21
View File
@@ -0,0 +1,21 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: managed-by-url-test
labels:
test: "true"
spec:
replicas: 1
selector:
matchLabels:
app: managed-by-url-test
template:
metadata:
labels:
app: managed-by-url-test
spec:
containers:
- name: managed-by-url-test
image: quay.io/argoprojlabs/argocd-e2e-container:0.2
ports:
- containerPort: 80
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: managed-by-url-test
spec:
selector:
app: managed-by-url-test
ports:
- port: 80
targetPort: 80
type: ClusterIP
@@ -1267,7 +1267,7 @@ Are you sure you want to disable auto-sync and rollback application '${props.mat
.pipe(
mergeMap(app => {
const fallbackTree = {
nodes: app.status.resources.map(res => ({...res, parentRefs: [], info: [], resourceVersion: '', uid: ''})),
nodes: app.status.resources.map(res => ({...res, parentRefs: [] as any[], info: [] as any[], resourceVersion: '', uid: ''})),
orphanedNodes: [],
hosts: []
} as appModels.ApplicationTree;
@@ -4,7 +4,16 @@ import * as classNames from 'classnames';
import * as models from '../../../shared/models';
import {ResourceIcon} from '../resource-icon';
import {ResourceLabel} from '../resource-label';
import {ComparisonStatusIcon, HealthStatusIcon, nodeKey, isSameNode, createdOrNodeKey, resourceStatusToResourceNode} from '../utils';
import {
ComparisonStatusIcon,
HealthStatusIcon,
nodeKey,
isSameNode,
createdOrNodeKey,
resourceStatusToResourceNode,
getApplicationLinkURLFromNode,
getManagedByURLFromNode
} from '../utils';
import {AppDetailsPreferences} from '../../../shared/services';
import {Consumer} from '../../../shared/context';
import Moment from 'react-moment';
@@ -185,16 +194,26 @@ export const ApplicationResourceList = (props: ApplicationResourceListProps) =>
<span className='application-details__item_text'>{res.name}</span>
{res.kind === 'Application' && (
<Consumer>
{ctx => (
<span className='application-details__external_link'>
<a
href={ctx.baseHref + 'applications/' + res.namespace + '/' + res.name}
onClick={e => e.stopPropagation()}
title='Open application'>
<i className='fa fa-external-link-alt' />
</a>
</span>
)}
{ctx => {
// Get the node from the tree to access managed-by-url info
const node = nodeByKey.get(nodeKey(res));
const linkInfo = node
? getApplicationLinkURLFromNode(node, ctx.baseHref)
: {url: ctx.baseHref + 'applications/' + res.namespace + '/' + res.name, isExternal: false};
const managedByURL = node ? getManagedByURLFromNode(node) : null;
return (
<span className='application-details__external_link'>
<a
href={linkInfo.url}
target={linkInfo.isExternal ? '_blank' : undefined}
rel={linkInfo.isExternal ? 'noopener noreferrer' : undefined}
onClick={e => e.stopPropagation()}
title={managedByURL ? `Open application\nmanaged-by-url: ${managedByURL}` : 'Open application'}>
<i className='fa fa-external-link-alt' />
</a>
</span>
);
}}
</Consumer>
)}
</div>
@@ -22,7 +22,9 @@ import {
NodeId,
nodeKey,
PodHealthIcon,
getUsrMsgKeyToDisplay
getUsrMsgKeyToDisplay,
getApplicationLinkURLFromNode,
getManagedByURLFromNode
} from '../utils';
import {NodeUpdateAnimation} from './node-update-animation';
import {PodGroup} from '../application-pod-view/pod-view';
@@ -488,11 +490,22 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R
{comparisonStatus != null && <ComparisonStatusIcon status={comparisonStatus} resource={!rootNode && node} />}
{appNode && !rootNode && (
<Consumer>
{ctx => (
<a href={ctx.baseHref + 'applications/' + node.namespace + '/' + node.name} title='Open application'>
<i className='fa fa-external-link-alt' />
</a>
)}
{ctx => {
// For nested applications, use the node's data to construct the URL
const linkInfo = getApplicationLinkURLFromNode(node, ctx.baseHref);
return (
<a
href={linkInfo.url}
target={linkInfo.isExternal ? '_blank' : undefined}
rel={linkInfo.isExternal ? 'noopener noreferrer' : undefined}
onClick={e => {
e.stopPropagation();
}}
title={getManagedByURLFromNode(node) ? `Open application\nmanaged-by-url: ${getManagedByURLFromNode(node)}` : 'Open application'}>
<i className='fa fa-external-link-alt' />
</a>
);
}}
</Consumer>
)}
<ApplicationURLs urls={rootNode ? extLinks : node.networkingInfo && node.networkingInfo.externalURLs} />
@@ -771,11 +784,22 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
{comparisonStatus != null && <ComparisonStatusIcon status={comparisonStatus} resource={!rootNode && node} />}
{appNode && !rootNode && (
<Consumer>
{ctx => (
<a href={ctx.baseHref + 'applications/' + node.namespace + '/' + node.name} title='Open application'>
<i className='fa fa-external-link-alt' />
</a>
)}
{ctx => {
// For nested applications, use the node's data to construct the URL
const linkInfo = getApplicationLinkURLFromNode(node, ctx.baseHref);
return (
<a
href={linkInfo.url}
target={linkInfo.isExternal ? '_blank' : undefined}
rel={linkInfo.isExternal ? 'noopener noreferrer' : undefined}
onClick={e => {
e.stopPropagation();
}}
title={getManagedByURLFromNode(node) ? `Open application\nmanaged-by-url: ${getManagedByURLFromNode(node)}` : 'Open application'}>
<i className='fa fa-external-link-alt' />
</a>
);
}}
</Consumer>
)}
<ApplicationURLs urls={rootNode ? extLinks : node.networkingInfo && node.networkingInfo.externalURLs} />
@@ -800,7 +824,7 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
</span>
) : null}
{(node.info || [])
.filter(tag => !tag.name.includes('Node'))
.filter(tag => !tag.name.includes('Node') && tag.name !== 'managed-by-url')
.slice(0, 4)
.map((tag, i) => {
return <NodeInfoDetails tag={tag} kind={node.kind} key={i} />;
@@ -1,3 +1,5 @@
@import 'node_modules/argo-ui/src/styles/config';
.applications-table {
.application-name {
@@ -38,8 +40,27 @@
min-width: 0;
}
.applications-table-source__labels {
max-width: 40%;
.applications-table-source__labels {
max-width: 40%;
}
}
.applications-list__external-link {
button {
background: none;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
color: inherit;
&:hover {
color: $argo-color-teal-5;
}
i {
font-size: 14px;
}
}
}
}
@@ -7,7 +7,7 @@ import {Consumer, Context} from '../../../shared/context';
import * as models from '../../../shared/models';
import {ApplicationURLs} from '../application-urls';
import * as AppUtils from '../utils';
import {getAppDefaultSource, OperationState} from '../utils';
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
import {ApplicationsLabels} from './applications-labels';
import {ApplicationsSource} from './applications-source';
import {services} from '../../../shared/services';
@@ -52,121 +52,142 @@ export const ApplicationsTable = (props: {
const favList = pref.appList.favoritesAppList || [];
return (
<div className='applications-table argo-table-list argo-table-list--clickable'>
{props.applications.map((app, i) => (
<div
key={AppUtils.appInstanceName(app)}
className={`argo-table-list__row
applications-list__entry applications-list__entry--health-${app.status.health.status} ${selectedApp === i ? 'applications-tiles__selected' : ''}`}>
{props.applications.map((app, i) => {
return (
<div
className={`row applications-list__table-row ${app.status.sourceHydrator?.currentOperation ? 'applications-table-row--with-hydrator' : ''}`}
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {}, {event: e})}>
<div className='columns small-4'>
<div className='row'>
<div className=' columns small-2'>
<div>
<Tooltip content={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
<button
onClick={e => {
e.stopPropagation();
favList?.includes(app.metadata.name)
? favList.splice(favList.indexOf(app.metadata.name), 1)
: favList.push(app.metadata.name);
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
}}>
<i
className={favList?.includes(app.metadata.name) ? 'fas fa-star' : 'far fa-star'}
style={{
cursor: 'pointer',
marginRight: '7px',
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
key={AppUtils.appInstanceName(app)}
className={`argo-table-list__row
applications-list__entry applications-list__entry--health-${app.status.health.status} ${selectedApp === i ? 'applications-tiles__selected' : ''}`}>
<div
className={`row applications-list__table-row ${app.status.sourceHydrator?.currentOperation ? 'applications-table-row--with-hydrator' : ''}`}
onClick={e => ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {}, {event: e})}>
<div className='columns small-4'>
<div className='row'>
<div className=' columns small-2'>
<div>
<Tooltip content={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
<button
onClick={e => {
e.stopPropagation();
favList?.includes(app.metadata.name)
? favList.splice(favList.indexOf(app.metadata.name), 1)
: favList.push(app.metadata.name);
services.viewPreferences.updatePreferences({appList: {...pref.appList, favoritesAppList: favList}});
}}>
<i
className={favList?.includes(app.metadata.name) ? 'fas fa-star' : 'far fa-star'}
style={{
cursor: 'pointer',
marginRight: '7px',
color: favList?.includes(app.metadata.name) ? '#FFCE25' : '#8fa4b1'
}}
/>
</button>
</Tooltip>
<ApplicationURLs urls={app.status.summary.externalURLs} />
</div>
</div>
<div className='show-for-xxlarge columns small-4'>Project:</div>
<div className='columns small-12 xxlarge-6'>{app.spec.project}</div>
</div>
<div className='row'>
<div className=' columns small-2' />
<div className='show-for-xxlarge columns small-4'>Name:</div>
<div className='columns small-12 xxlarge-6'>
<Tooltip
content={
<>
{app.metadata.name}
<br />
<Moment fromNow={true} ago={true}>
{app.metadata.creationTimestamp}
</Moment>
</>
}>
<span>{app.metadata.name}</span>
</Tooltip>
<ApplicationURLs urls={app.status.summary.externalURLs} />
{/* External link icon for managed-by-url */}
{(() => {
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
return (
<button
onClick={e => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
}
}}
style={{marginLeft: '0.5em'}}
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(app) || 'none'}`}>
<i className='fa fa-external-link-alt' />
</button>
);
})()}
</div>
</div>
<div className='show-for-xxlarge columns small-4'>Name:</div>
<div className='columns small-12 xxlarge-6'>
<Tooltip
content={
<>
{app.metadata.name}
<br />
<Moment fromNow={true} ago={true}>
{app.metadata.creationTimestamp}
</Moment>
</>
}>
<span className='application-name'>{app.metadata.name}</span>
</Tooltip>
</div>
</div>
<div className='row'>
<div className=' columns small-2' />
<div className='show-for-xxlarge columns small-4'>Project:</div>
<div className='columns small-12 xxlarge-6'>{app.spec.project}</div>
</div>
</div>
<div className='columns small-6'>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Source:</div>
<div className='columns small-12 xxlarge-10 applications-table-source' style={{position: 'relative'}}>
<div className='applications-table-source__link'>
<ApplicationsSource source={getAppDefaultSource(app)} />
<div className='columns small-6'>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Source:</div>
<div className='columns small-12 xxlarge-10 applications-table-source' style={{position: 'relative'}}>
<div className='applications-table-source__link'>
<ApplicationsSource source={getAppDefaultSource(app)} />
</div>
<div className='applications-table-source__labels'>
<ApplicationsLabels app={app} />
</div>
</div>
<div className='applications-table-source__labels'>
<ApplicationsLabels app={app} />
</div>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Destination:</div>
<div className='columns small-12 xxlarge-10'>
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />/{app.spec.destination.namespace}
</div>
</div>
</div>
<div className='row'>
<div className='show-for-xxlarge columns small-2'>Destination:</div>
<div className='columns small-12 xxlarge-10'>
<Cluster server={app.spec.destination.server} name={app.spec.destination.name} />/{app.spec.destination.namespace}
</div>
</div>
</div>
<div className='columns small-2'>
<AppUtils.HealthStatusIcon state={app.status.health} /> <span>{app.status.health.status}</span> <br />
{app.status.sourceHydrator?.currentOperation && (
<>
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
<span>{app.status.sourceHydrator.currentOperation.phase}</span> <br />
</>
)}
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} />
<span>{app.status.sync.status}</span> <OperationState app={app} quiet={true} />
<DropDownMenu
anchor={() => (
<button className='argo-button argo-button--light argo-button--lg argo-button--short'>
<i className='fa fa-ellipsis-v' />
</button>
<div className='columns small-2'>
<AppUtils.HealthStatusIcon state={app.status.health} /> <span>{app.status.health.status}</span> <br />
{app.status.sourceHydrator?.currentOperation && (
<>
<AppUtils.HydrateOperationPhaseIcon operationState={app.status.sourceHydrator.currentOperation} />{' '}
<span>{app.status.sourceHydrator.currentOperation.phase}</span> <br />
</>
)}
items={[
{
title: 'Sync',
iconClassName: 'fa fa-fw fa-sync',
action: () => props.syncApplication(app.metadata.name, app.metadata.namespace)
},
{
title: 'Refresh',
iconClassName: 'fa fa-fw fa-redo',
action: () => props.refreshApplication(app.metadata.name, app.metadata.namespace)
},
{
title: 'Delete',
iconClassName: 'fa fa-fw fa-times-circle',
action: () => props.deleteApplication(app.metadata.name, app.metadata.namespace)
}
]}
/>
<AppUtils.ComparisonStatusIcon status={app.status.sync.status} />
<span>{app.status.sync.status}</span> <OperationState app={app} quiet={true} />
<DropDownMenu
anchor={() => (
<button className='argo-button argo-button--light argo-button--lg argo-button--short'>
<i className='fa fa-ellipsis-v' />
</button>
)}
items={[
{
title: 'Sync',
iconClassName: 'fa fa-fw fa-sync',
action: () => props.syncApplication(app.metadata.name, app.metadata.namespace)
},
{
title: 'Refresh',
iconClassName: 'fa fa-fw fa-redo',
action: () => props.refreshApplication(app.metadata.name, app.metadata.namespace)
},
{
title: 'Delete',
iconClassName: 'fa fa-fw fa-times-circle',
action: () => props.deleteApplication(app.metadata.name, app.metadata.namespace)
}
]}
/>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
);
}}
@@ -51,3 +51,22 @@
display: none !important;
}
}
.applications-list__external-link {
button {
background: none;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
color: inherit;
&:hover {
color: $argo-color-teal-5;
}
i {
font-size: 14px;
}
}
}
@@ -7,7 +7,7 @@ import {Consumer, Context, AuthSettingsCtx} from '../../../shared/context';
import * as models from '../../../shared/models';
import {ApplicationURLs} from '../application-urls';
import * as AppUtils from '../utils';
import {getAppDefaultSource, OperationState} from '../utils';
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
import {services} from '../../../shared/services';
import './applications-tiles.scss';
@@ -110,6 +110,7 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
const source = getAppDefaultSource(app);
const isOci = source?.repoURL?.startsWith('oci://');
const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
return (
<div
key={AppUtils.appInstanceName(app)}
@@ -140,6 +141,18 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat
<div className={app.status.summary.externalURLs?.length > 0 ? 'columns small-2' : 'columns small-1'}>
<div className='applications-list__external-link'>
<ApplicationURLs urls={app.status.summary.externalURLs} />
<button
onClick={e => {
e.stopPropagation();
if (linkInfo.isExternal) {
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
} else {
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`);
}
}}
title={getManagedByURL(app) ? `Managed by: ${getManagedByURL(app)}` : 'Open application'}>
<i className='fa fa-external-link-alt' />
</button>
<Tooltip content={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}>
<button
className='large-text-height'
@@ -8,6 +8,7 @@ import * as moment from 'moment';
import {BehaviorSubject, combineLatest, concat, from, fromEvent, Observable, Observer, Subscription} from 'rxjs';
import {debounceTime, map} from 'rxjs/operators';
import {AppContext, Context, ContextApis} from '../../shared/context';
import {isValidURL} from '../../shared/utils';
import {ResourceTreeNode} from './application-resource-tree/application-resource-tree';
import {CheckboxField, COLORS, ErrorNotification, Revision} from '../../shared/components';
@@ -1772,3 +1773,89 @@ export const podRequests = {
CPU: 'Requests (CPU)',
MEMORY: 'Requests (MEM)'
} as const;
/**
* Gets the managed-by-url annotation from an application if it exists
* @param app The application object
* @returns The managed-by-url value or null if not present
*/
export function getManagedByURL(app: any): string | null {
return app?.metadata?.annotations?.['argocd.argoproj.io/managed-by-url'] || null;
}
/**
* Gets the managed-by-url from a resource node's info field
* @param node The resource node object
* @returns The managed-by-url value or null if not present
*/
export function getManagedByURLFromNode(node: any): string | null {
if (!node?.info) {
return null;
}
const managedByURLInfo = node.info.find((info: any) => info.name === 'managed-by-url');
return managedByURLInfo?.value || null;
}
/**
* Gets the correct URL for an application link, considering managed-by-url annotation
* @param app The application object
* @param baseHref The current instance's base href
* @param node Optional resource node to get managed-by-url from info field
* @returns The URL to use for the application link
*/
export function getApplicationLinkURL(app: any, baseHref: string, node?: any): {url: string; isExternal: boolean} {
// First try to get managed-by-url from the node's info field (for nested applications)
let managedByURL = node ? getManagedByURLFromNode(node) : null;
// If not found in node, try the application's metadata
if (!managedByURL) {
managedByURL = getManagedByURL(app);
}
let url, isExternal;
if (managedByURL) {
// Validate the managed-by URL using the same validation as external links
if (!isValidURL(managedByURL)) {
// If URL is invalid, fall back to local URL for security
console.warn(`Invalid managed-by URL for application ${app.metadata.name}: ${managedByURL}`);
url = baseHref + 'applications/' + app.metadata.namespace + '/' + app.metadata.name;
isExternal = false;
} else {
url = managedByURL + '/applications/' + app.metadata.namespace + '/' + app.metadata.name;
isExternal = true;
}
} else {
url = baseHref + 'applications/' + app.metadata.namespace + '/' + app.metadata.name;
isExternal = false;
}
return {url, isExternal};
}
/**
* Gets the correct URL for an application link from a resource node, considering managed-by-url annotation
* @param node The resource node representing an application
* @param baseHref The current instance's base href
* @returns The URL to use for the application link
*/
export function getApplicationLinkURLFromNode(node: any, baseHref: string): {url: string; isExternal: boolean} {
const managedByURL = getManagedByURLFromNode(node);
let url, isExternal;
if (managedByURL) {
// Validate the managed-by URL using the same validation as external links
if (!isValidURL(managedByURL)) {
// If URL is invalid, fall back to local URL for security
console.warn(`Invalid managed-by URL for application ${node.name}: ${managedByURL}`);
url = baseHref + 'applications/' + node.namespace + '/' + node.name;
isExternal = false;
} else {
url = managedByURL + '/applications/' + node.namespace + '/' + node.name;
isExternal = true;
}
} else {
url = baseHref + 'applications/' + node.namespace + '/' + node.name;
isExternal = false;
}
return {url, isExternal};
}
+23
View File
@@ -407,6 +407,29 @@ func ValidateRepo(
return conditions, nil
}
// ValidateManagedByURL validates the managed-by-url annotation on applications to ensure it contains a valid URL
func ValidateManagedByURL(app *argoappv1.Application) []argoappv1.ApplicationCondition {
conditions := make([]argoappv1.ApplicationCondition, 0)
if app.Annotations == nil {
return conditions
}
managedByURL, exists := app.Annotations[argoappv1.AnnotationKeyManagedByURL]
if !exists || managedByURL == "" {
return conditions
}
if err := settings.ValidateExternalURL(managedByURL); err != nil {
conditions = append(conditions, argoappv1.ApplicationCondition{
Type: argoappv1.ApplicationConditionInvalidSpecError,
Message: fmt.Sprintf("invalid managed-by URL: %v", err),
})
}
return conditions
}
func validateRepo(ctx context.Context,
app *argoappv1.Application,
db db.ArgoDB,
+167
View File
@@ -1739,3 +1739,170 @@ func TestGetAppEventLabels(t *testing.T) {
})
}
}
func TestValidateManagedByURL(t *testing.T) {
tests := []struct {
name string
app *argoappv1.Application
wantErr bool
wantErrMsg string
}{
{
name: "Valid HTTPS URL",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "https://argocd.example.com",
},
},
},
wantErr: false,
},
{
name: "Valid HTTP URL",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "http://argocd.example.com",
},
},
},
wantErr: false,
},
{
name: "Valid localhost HTTPS URL",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "https://localhost:8081",
},
},
},
wantErr: false,
},
{
name: "Valid localhost HTTP URL",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "http://localhost:8081",
},
},
},
wantErr: false,
},
{
name: "Valid 127.0.0.1 URL",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "http://127.0.0.1:8081",
},
},
},
wantErr: false,
},
{
name: "No annotations",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{},
},
wantErr: false,
},
{
name: "Empty managed-by-url",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "",
},
},
},
wantErr: false,
},
{
name: "Missing managed-by-url annotation",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
"other.annotation": "value",
},
},
},
wantErr: false,
},
{
name: "Invalid protocol - javascript",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "javascript:alert('xss')",
},
},
},
wantErr: true,
wantErrMsg: "invalid managed-by URL: URL must include http or https protocol",
},
{
name: "Invalid protocol - data",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "data:text/html,<script>alert('xss')</script>",
},
},
},
wantErr: true,
wantErrMsg: "invalid managed-by URL: URL must include http or https protocol",
},
{
name: "Invalid protocol - file",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "file:///etc/passwd",
},
},
},
wantErr: true,
wantErrMsg: "invalid managed-by URL: URL must include http or https protocol",
},
{
name: "Invalid URL format",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "not-a-url",
},
},
},
wantErr: true,
wantErrMsg: "invalid managed-by URL: URL must include http or https protocol",
},
{
name: "URL with path and query",
app: &argoappv1.Application{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
argoappv1.AnnotationKeyManagedByURL: "https://argocd.example.com/applications?namespace=default",
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conditions := ValidateManagedByURL(tt.app)
if tt.wantErr {
require.Len(t, conditions, 1, "Expected exactly one validation condition")
assert.Equal(t, argoappv1.ApplicationConditionInvalidSpecError, conditions[0].Type)
assert.Contains(t, conditions[0].Message, tt.wantErrMsg)
} else {
assert.Empty(t, conditions, "Expected no validation conditions for valid URL")
}
})
}
}
+5 -5
View File
@@ -1451,11 +1451,11 @@ func updateSettingsFromConfigMap(settings *ArgoCDSettings, argoCDCM *corev1.Conf
settings.UiBannerPermanent = argoCDCM.Data[settingUIBannerPermanentKey] == "true"
settings.UiBannerPosition = argoCDCM.Data[settingUIBannerPositionKey]
settings.BinaryUrls = getDownloadBinaryUrlsFromConfigMap(argoCDCM)
if err := validateExternalURL(argoCDCM.Data[settingURLKey]); err != nil {
if err := ValidateExternalURL(argoCDCM.Data[settingURLKey]); err != nil {
log.Warnf("Failed to validate URL in configmap: %v", err)
}
settings.URL = argoCDCM.Data[settingURLKey]
if err := validateExternalURL(argoCDCM.Data[settingUIBannerURLKey]); err != nil {
if err := ValidateExternalURL(argoCDCM.Data[settingUIBannerURLKey]); err != nil {
log.Warnf("Failed to validate UI banner URL in configmap: %v", err)
}
if argoCDCM.Data[settingAdditionalUrlsKey] != "" {
@@ -1464,7 +1464,7 @@ func updateSettingsFromConfigMap(settings *ArgoCDSettings, argoCDCM *corev1.Conf
}
}
for _, url := range settings.AdditionalURLs {
if err := validateExternalURL(url); err != nil {
if err := ValidateExternalURL(url); err != nil {
log.Warnf("Failed to validate external URL in configmap: %v", err)
}
}
@@ -1515,8 +1515,8 @@ func getExtensionConfigs(cmData map[string]string) map[string]string {
return result
}
// validateExternalURL ensures the external URL that is set on the configmap is valid
func validateExternalURL(u string) error {
// ValidateExternalURL ensures the external URL that is set on the configmap is valid
func ValidateExternalURL(u string) error {
if u == "" {
return nil
}
+1 -1
View File
@@ -1089,7 +1089,7 @@ func Test_validateExternalURL(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateExternalURL(tt.url)
err := ValidateExternalURL(tt.url)
if tt.errMsg != "" {
assert.EqualError(t, err, tt.errMsg)
} else {