mirror of
https://github.com/hanzoai/deploy.git
synced 2026-10-07 08:44:50 +00:00
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:
@@ -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
|
||||
|
||||
Vendored
+11
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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, ", "))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
+30
-11
@@ -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>
|
||||
|
||||
+36
-12
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user