mirror of
https://github.com/argoproj/argo-cd.git
synced 2026-03-28 11:28:48 +01:00
Compare commits
1 Commits
master
...
renovate/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c2675a2d |
2
.github/workflows/cherry-pick-single.yml
vendored
2
.github/workflows/cherry-pick-single.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
with:
|
||||
app-id: ${{ secrets.CHERRYPICK_APP_ID }}
|
||||
private-key: ${{ secrets.CHERRYPICK_APP_PRIVATE_KEY }}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## What is Argo CD?
|
||||
|
||||
Argo CD is a declarative GitOps continuous delivery tool for Kubernetes.
|
||||
Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes.
|
||||
|
||||

|
||||
|
||||
@@ -45,7 +45,7 @@ Check live demo at https://cd.apps.argoproj.io/.
|
||||
|
||||
You can reach the Argo CD community and developers via the following channels:
|
||||
|
||||
* Q & A : [GitHub Discussions](https://github.com/argoproj/argo-cd/discussions)
|
||||
* Q & A : [Github Discussions](https://github.com/argoproj/argo-cd/discussions)
|
||||
* Chat : [The #argo-cd Slack channel](https://argoproj.github.io/community/join-slack)
|
||||
* Contributors Office Hours: [Every Thursday](https://calendar.google.com/calendar/u/0/embed?src=argoproj@gmail.com) | [Agenda](https://docs.google.com/document/d/1xkoFkVviB70YBzSEa4bDnu-rUZ1sIFtwKKG1Uw8XsY8)
|
||||
* User Community meeting: [First Wednesday of the month](https://calendar.google.com/calendar/u/0/embed?src=argoproj@gmail.com) | [Agenda](https://docs.google.com/document/d/1ttgw98MO45Dq7ZUHpIiOIEfbyeitKHNfMjbY5dLLMKQ)
|
||||
|
||||
@@ -848,9 +848,7 @@ func (m *appStateManager) CompareAppState(app *v1alpha1.Application, project *v1
|
||||
log.Errorf("CompareAppState error getting server side diff dry run applier: %s", err)
|
||||
conditions = append(conditions, v1alpha1.ApplicationCondition{Type: v1alpha1.ApplicationConditionUnknownError, Message: err.Error(), LastTransitionTime: &now})
|
||||
}
|
||||
if cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
defer cleanup()
|
||||
diffConfigBuilder.WithServerSideDryRunner(diff.NewK8sServerSideDryRunner(applier))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Verification of Argo CD Artifacts
|
||||
|
||||
## Prerequisites
|
||||
- cosign `v2.0.0` or higher [installation instructions](https://docs.sigstore.dev/cosign/system_config/installation/)
|
||||
- cosign `v2.0.0` or higher [installation instructions](https://docs.sigstore.dev/cosign/installation)
|
||||
- slsa-verifier [installation instructions](https://github.com/slsa-framework/slsa-verifier#installation)
|
||||
- crane [installation instructions](https://github.com/google/go-containerregistry/blob/main/cmd/crane/README.md) (for container verification only)
|
||||
|
||||
@@ -154,4 +154,4 @@ slsa-verifier verify-artifact sbom.tar.gz \
|
||||
> [!NOTE]
|
||||
> We encourage all users to verify signatures and provenances with your admission/policy controller of choice. Doing so will verify that an image was built by us before it's deployed on your Kubernetes cluster.
|
||||
|
||||
Cosign signatures and SLSA provenances are compatible with several types of admission controllers. Please see the [cosign documentation](https://docs.sigstore.dev/policy-controller/overview/) and [slsa-github-generator](https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/container/README.md#verification) for supported controllers.
|
||||
Cosign signatures and SLSA provenances are compatible with several types of admission controllers. Please see the [cosign documentation](https://docs.sigstore.dev/cosign/overview/#kubernetes-integrations) and [slsa-github-generator](https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/container/README.md#verification) for supported controllers.
|
||||
|
||||
110
gitops-engine/pkg/cache/cluster.go
vendored
110
gitops-engine/pkg/cache/cluster.go
vendored
@@ -220,7 +220,7 @@ func NewClusterCache(config *rest.Config, opts ...UpdateSettingsFunc) *clusterCa
|
||||
listRetryLimit: 1,
|
||||
listRetryUseBackoff: false,
|
||||
listRetryFunc: ListRetryFuncNever,
|
||||
parentUIDToChildren: make(map[types.UID]map[kube.ResourceKey]struct{}),
|
||||
parentUIDToChildren: make(map[types.UID][]kube.ResourceKey),
|
||||
}
|
||||
for i := range opts {
|
||||
opts[i](cache)
|
||||
@@ -280,11 +280,10 @@ type clusterCache struct {
|
||||
|
||||
respectRBAC int
|
||||
|
||||
// Parent-to-children index for O(1) child lookup during hierarchy traversal
|
||||
// Maps any resource's UID to a set of its direct children's ResourceKeys
|
||||
// Using a set eliminates O(k) duplicate checking on insertions
|
||||
// Used for cross-namespace hierarchy traversal; namespaced traversal still builds a graph
|
||||
parentUIDToChildren map[types.UID]map[kube.ResourceKey]struct{}
|
||||
// Parent-to-children index for O(1) hierarchy traversal
|
||||
// Maps any resource's UID to its direct children's ResourceKeys
|
||||
// Eliminates need for O(n) graph building during hierarchy traversal
|
||||
parentUIDToChildren map[types.UID][]kube.ResourceKey
|
||||
}
|
||||
|
||||
type clusterCacheSync struct {
|
||||
@@ -505,35 +504,27 @@ func (c *clusterCache) setNode(n *Resource) {
|
||||
for k, v := range ns {
|
||||
// update child resource owner references
|
||||
if n.isInferredParentOf != nil && mightHaveInferredOwner(v) {
|
||||
shouldBeParent := n.isInferredParentOf(k)
|
||||
v.setOwnerRef(n.toOwnerRef(), shouldBeParent)
|
||||
// Update index inline for inferred ref changes.
|
||||
// Note: The removal case (shouldBeParent=false) is currently unreachable for
|
||||
// StatefulSet→PVC relationships because Kubernetes makes volumeClaimTemplates
|
||||
// immutable. We include it for defensive correctness and future-proofing.
|
||||
if n.Ref.UID != "" {
|
||||
if shouldBeParent {
|
||||
c.addToParentUIDToChildren(n.Ref.UID, k)
|
||||
} else {
|
||||
c.removeFromParentUIDToChildren(n.Ref.UID, k)
|
||||
}
|
||||
}
|
||||
v.setOwnerRef(n.toOwnerRef(), n.isInferredParentOf(k))
|
||||
}
|
||||
if mightHaveInferredOwner(n) && v.isInferredParentOf != nil {
|
||||
childKey := n.ResourceKey()
|
||||
shouldBeParent := v.isInferredParentOf(childKey)
|
||||
n.setOwnerRef(v.toOwnerRef(), shouldBeParent)
|
||||
// Update index inline for inferred ref changes.
|
||||
// Note: The removal case (shouldBeParent=false) is currently unreachable for
|
||||
// StatefulSet→PVC relationships because Kubernetes makes volumeClaimTemplates
|
||||
// immutable. We include it for defensive correctness and future-proofing.
|
||||
if v.Ref.UID != "" {
|
||||
if shouldBeParent {
|
||||
c.addToParentUIDToChildren(v.Ref.UID, childKey)
|
||||
} else {
|
||||
c.removeFromParentUIDToChildren(v.Ref.UID, childKey)
|
||||
}
|
||||
}
|
||||
n.setOwnerRef(v.toOwnerRef(), v.isInferredParentOf(n.ResourceKey()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildParentToChildrenIndex rebuilds the parent-to-children index after a full sync
|
||||
// This is called after initial sync to ensure all parent-child relationships are tracked
|
||||
func (c *clusterCache) rebuildParentToChildrenIndex() {
|
||||
// Clear existing index
|
||||
c.parentUIDToChildren = make(map[types.UID][]kube.ResourceKey)
|
||||
|
||||
// Rebuild parent-to-children index from all resources with owner refs
|
||||
for _, resource := range c.resources {
|
||||
key := resource.ResourceKey()
|
||||
for _, ownerRef := range resource.OwnerRefs {
|
||||
if ownerRef.UID != "" {
|
||||
c.addToParentUIDToChildren(ownerRef.UID, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,29 +532,31 @@ func (c *clusterCache) setNode(n *Resource) {
|
||||
|
||||
// addToParentUIDToChildren adds a child to the parent-to-children index
|
||||
func (c *clusterCache) addToParentUIDToChildren(parentUID types.UID, childKey kube.ResourceKey) {
|
||||
// Get or create the set for this parent
|
||||
childrenSet := c.parentUIDToChildren[parentUID]
|
||||
if childrenSet == nil {
|
||||
childrenSet = make(map[kube.ResourceKey]struct{})
|
||||
c.parentUIDToChildren[parentUID] = childrenSet
|
||||
// Check if child is already in the list to avoid duplicates
|
||||
children := c.parentUIDToChildren[parentUID]
|
||||
for _, existing := range children {
|
||||
if existing == childKey {
|
||||
return // Already exists, no need to add
|
||||
}
|
||||
}
|
||||
// Add child to set (O(1) operation, automatically handles duplicates)
|
||||
childrenSet[childKey] = struct{}{}
|
||||
c.parentUIDToChildren[parentUID] = append(children, childKey)
|
||||
}
|
||||
|
||||
// removeFromParentUIDToChildren removes a child from the parent-to-children index
|
||||
func (c *clusterCache) removeFromParentUIDToChildren(parentUID types.UID, childKey kube.ResourceKey) {
|
||||
childrenSet := c.parentUIDToChildren[parentUID]
|
||||
if childrenSet == nil {
|
||||
return
|
||||
}
|
||||
children := c.parentUIDToChildren[parentUID]
|
||||
for i, existing := range children {
|
||||
if existing == childKey {
|
||||
// Remove by swapping with last element and truncating
|
||||
children[i] = children[len(children)-1]
|
||||
c.parentUIDToChildren[parentUID] = children[:len(children)-1]
|
||||
|
||||
// Remove child from set (O(1) operation)
|
||||
delete(childrenSet, childKey)
|
||||
|
||||
// Clean up empty sets to avoid memory leaks
|
||||
if len(childrenSet) == 0 {
|
||||
delete(c.parentUIDToChildren, parentUID)
|
||||
// Clean up empty entries
|
||||
if len(c.parentUIDToChildren[parentUID]) == 0 {
|
||||
delete(c.parentUIDToChildren, parentUID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,7 +1013,7 @@ func (c *clusterCache) sync() error {
|
||||
c.apisMeta = make(map[schema.GroupKind]*apiMeta)
|
||||
c.resources = make(map[kube.ResourceKey]*Resource)
|
||||
c.namespacedResources = make(map[schema.GroupKind]bool)
|
||||
c.parentUIDToChildren = make(map[types.UID]map[kube.ResourceKey]struct{})
|
||||
c.parentUIDToChildren = make(map[types.UID][]kube.ResourceKey)
|
||||
config := c.config
|
||||
version, err := c.kubectl.GetServerVersion(config)
|
||||
if err != nil {
|
||||
@@ -1119,6 +1112,9 @@ func (c *clusterCache) sync() error {
|
||||
return fmt.Errorf("failed to sync cluster %s: %w", c.config.Host, err)
|
||||
}
|
||||
|
||||
// Rebuild orphaned children index after all resources are loaded
|
||||
c.rebuildParentToChildrenIndex()
|
||||
|
||||
c.log.Info("Cluster successfully synced")
|
||||
return nil
|
||||
}
|
||||
@@ -1259,8 +1255,8 @@ func (c *clusterCache) processCrossNamespaceChildren(
|
||||
}
|
||||
|
||||
// Use parent-to-children index for O(1) lookup of direct children
|
||||
childrenSet := c.parentUIDToChildren[clusterResource.Ref.UID]
|
||||
for childKey := range childrenSet {
|
||||
childKeys := c.parentUIDToChildren[clusterResource.Ref.UID]
|
||||
for _, childKey := range childKeys {
|
||||
child := c.resources[childKey]
|
||||
if child == nil {
|
||||
continue
|
||||
@@ -1313,8 +1309,8 @@ func (c *clusterCache) iterateChildrenUsingIndex(
|
||||
action func(resource *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool,
|
||||
) {
|
||||
// Look up direct children of this parent using the index
|
||||
childrenSet := c.parentUIDToChildren[parent.Ref.UID]
|
||||
for childKey := range childrenSet {
|
||||
childKeys := c.parentUIDToChildren[parent.Ref.UID]
|
||||
for _, childKey := range childKeys {
|
||||
if actionCallState[childKey] != notCalled {
|
||||
continue // action() already called or in progress
|
||||
}
|
||||
@@ -1634,10 +1630,6 @@ func (c *clusterCache) onNodeRemoved(key kube.ResourceKey) {
|
||||
for k, v := range ns {
|
||||
if mightHaveInferredOwner(v) && existing.isInferredParentOf(k) {
|
||||
v.setOwnerRef(existing.toOwnerRef(), false)
|
||||
// Update index inline when removing inferred ref
|
||||
if existing.Ref.UID != "" {
|
||||
c.removeFromParentUIDToChildren(existing.Ref.UID, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
345
gitops-engine/pkg/cache/cluster_test.go
vendored
345
gitops-engine/pkg/cache/cluster_test.go
vendored
@@ -416,128 +416,6 @@ func TestStatefulSetOwnershipInferred(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatefulSetPVC_ParentToChildrenIndex verifies that inferred StatefulSet → PVC
|
||||
// relationships are correctly captured in the parentUIDToChildren index during initial sync.
|
||||
//
|
||||
// The index is updated inline when inferred owner refs are added in setNode()
|
||||
// (see the inferred parent handling section in clusterCache.setNode).
|
||||
func TestStatefulSetPVC_ParentToChildrenIndex(t *testing.T) {
|
||||
stsUID := types.UID("sts-uid-123")
|
||||
|
||||
// StatefulSet with volumeClaimTemplate named "data"
|
||||
sts := &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: kube.StatefulSetKind},
|
||||
ObjectMeta: metav1.ObjectMeta{UID: stsUID, Name: "web", Namespace: "default"},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "data"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
// PVCs that match the StatefulSet's volumeClaimTemplate pattern: <template>-<sts>-<ordinal>
|
||||
// These have NO explicit owner references - the relationship is INFERRED
|
||||
pvc0 := &corev1.PersistentVolumeClaim{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: kube.PersistentVolumeClaimKind},
|
||||
ObjectMeta: metav1.ObjectMeta{UID: "pvc-0-uid", Name: "data-web-0", Namespace: "default"},
|
||||
}
|
||||
pvc1 := &corev1.PersistentVolumeClaim{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: kube.PersistentVolumeClaimKind},
|
||||
ObjectMeta: metav1.ObjectMeta{UID: "pvc-1-uid", Name: "data-web-1", Namespace: "default"},
|
||||
}
|
||||
|
||||
// Create cluster with all resources
|
||||
// Must add PersistentVolumeClaim to API resources since it's not in the default set
|
||||
cluster := newCluster(t, sts, pvc0, pvc1).WithAPIResources([]kube.APIResourceInfo{{
|
||||
GroupKind: schema.GroupKind{Group: "", Kind: kube.PersistentVolumeClaimKind},
|
||||
GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumeclaims"},
|
||||
Meta: metav1.APIResource{Namespaced: true},
|
||||
}})
|
||||
err := cluster.EnsureSynced()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the parentUIDToChildren index contains the inferred relationships
|
||||
cluster.lock.RLock()
|
||||
defer cluster.lock.RUnlock()
|
||||
|
||||
pvc0Key := kube.ResourceKey{Group: "", Kind: kube.PersistentVolumeClaimKind, Namespace: "default", Name: "data-web-0"}
|
||||
pvc1Key := kube.ResourceKey{Group: "", Kind: kube.PersistentVolumeClaimKind, Namespace: "default", Name: "data-web-1"}
|
||||
|
||||
children, ok := cluster.parentUIDToChildren[stsUID]
|
||||
require.True(t, ok, "StatefulSet should have entry in parentUIDToChildren index")
|
||||
require.Contains(t, children, pvc0Key, "PVC data-web-0 should be in StatefulSet's children (inferred relationship)")
|
||||
require.Contains(t, children, pvc1Key, "PVC data-web-1 should be in StatefulSet's children (inferred relationship)")
|
||||
|
||||
// Also verify the OwnerRefs were set correctly on the PVCs
|
||||
pvc0Resource := cluster.resources[pvc0Key]
|
||||
require.NotNil(t, pvc0Resource)
|
||||
require.Len(t, pvc0Resource.OwnerRefs, 1, "PVC0 should have inferred owner ref")
|
||||
require.Equal(t, stsUID, pvc0Resource.OwnerRefs[0].UID, "PVC0 owner should be the StatefulSet")
|
||||
|
||||
pvc1Resource := cluster.resources[pvc1Key]
|
||||
require.NotNil(t, pvc1Resource)
|
||||
require.Len(t, pvc1Resource.OwnerRefs, 1, "PVC1 should have inferred owner ref")
|
||||
require.Equal(t, stsUID, pvc1Resource.OwnerRefs[0].UID, "PVC1 owner should be the StatefulSet")
|
||||
}
|
||||
|
||||
// TestStatefulSetPVC_WatchEvent_IndexUpdated verifies that when a PVC is added
|
||||
// via watch event (after initial sync), both the inferred owner reference AND
|
||||
// the parentUIDToChildren index are updated correctly.
|
||||
//
|
||||
// This tests the inline index update logic in setNode() which updates the index
|
||||
// immediately when inferred owner refs are added.
|
||||
func TestStatefulSetPVC_WatchEvent_IndexUpdated(t *testing.T) {
|
||||
stsUID := types.UID("sts-uid-456")
|
||||
|
||||
// StatefulSet with volumeClaimTemplate
|
||||
sts := &appsv1.StatefulSet{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: kube.StatefulSetKind},
|
||||
ObjectMeta: metav1.ObjectMeta{UID: stsUID, Name: "db", Namespace: "default"},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "storage"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
// Create cluster with ONLY the StatefulSet - PVC will be added via watch event
|
||||
cluster := newCluster(t, sts).WithAPIResources([]kube.APIResourceInfo{{
|
||||
GroupKind: schema.GroupKind{Group: "", Kind: kube.PersistentVolumeClaimKind},
|
||||
GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumeclaims"},
|
||||
Meta: metav1.APIResource{Namespaced: true},
|
||||
}})
|
||||
err := cluster.EnsureSynced()
|
||||
require.NoError(t, err)
|
||||
|
||||
// PVC that matches the StatefulSet's volumeClaimTemplate pattern
|
||||
// Added via watch event AFTER initial sync
|
||||
pvc := &corev1.PersistentVolumeClaim{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: kube.PersistentVolumeClaimKind},
|
||||
ObjectMeta: metav1.ObjectMeta{UID: "pvc-watch-uid", Name: "storage-db-0", Namespace: "default"},
|
||||
}
|
||||
|
||||
// Simulate watch event adding the PVC
|
||||
cluster.lock.Lock()
|
||||
cluster.setNode(cluster.newResource(mustToUnstructured(pvc)))
|
||||
cluster.lock.Unlock()
|
||||
|
||||
cluster.lock.RLock()
|
||||
defer cluster.lock.RUnlock()
|
||||
|
||||
pvcKey := kube.ResourceKey{Group: "", Kind: kube.PersistentVolumeClaimKind, Namespace: "default", Name: "storage-db-0"}
|
||||
|
||||
// Verify the OwnerRef IS correctly set
|
||||
pvcResource := cluster.resources[pvcKey]
|
||||
require.NotNil(t, pvcResource, "PVC should exist in cache")
|
||||
require.Len(t, pvcResource.OwnerRefs, 1, "PVC should have inferred owner ref from StatefulSet")
|
||||
require.Equal(t, stsUID, pvcResource.OwnerRefs[0].UID, "Owner should be the StatefulSet")
|
||||
|
||||
// Verify the index IS updated for inferred refs via watch events
|
||||
children, indexUpdated := cluster.parentUIDToChildren[stsUID]
|
||||
require.True(t, indexUpdated, "Index should be updated when inferred refs are added via watch events")
|
||||
require.Contains(t, children, pvcKey, "PVC should be in StatefulSet's children (inferred relationship)")
|
||||
}
|
||||
|
||||
func TestEnsureSyncedSingleNamespace(t *testing.T) {
|
||||
obj1 := &appsv1.Deployment{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
@@ -2420,226 +2298,3 @@ func TestIterateHierarchyV2_CircularOwnerChain_NoStackOverflow(t *testing.T) {
|
||||
assert.Equal(t, 1, visitCount["resource-a"], "resource-a should be visited exactly once")
|
||||
assert.Equal(t, 1, visitCount["resource-b"], "resource-b should be visited exactly once")
|
||||
}
|
||||
|
||||
// BenchmarkSync_ParentToChildrenIndex measures the overhead of parent-to-children index
|
||||
// operations during sync. This benchmark was created to investigate performance regression
|
||||
// reported in https://github.com/argoproj/argo-cd/issues/26863
|
||||
//
|
||||
// The index is now maintained with O(1) operations (set-based) and updated inline
|
||||
// in setNode() for both explicit and inferred owner refs. No rebuild is needed.
|
||||
//
|
||||
// This benchmark measures sync performance with resources that have owner references
|
||||
// to quantify the index-building overhead at different scales.
|
||||
func BenchmarkSync_ParentToChildrenIndex(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
totalResources int
|
||||
pctWithOwnerRefs int // Percentage of resources with owner references
|
||||
}{
|
||||
// Baseline: no owner refs (index operations are no-ops)
|
||||
{"1000res_0pctOwnerRefs", 1000, 0},
|
||||
{"5000res_0pctOwnerRefs", 5000, 0},
|
||||
{"10000res_0pctOwnerRefs", 10000, 0},
|
||||
|
||||
// Typical case: ~80% of resources have owner refs (pods owned by RS, RS owned by Deployment)
|
||||
{"1000res_80pctOwnerRefs", 1000, 80},
|
||||
{"5000res_80pctOwnerRefs", 5000, 80},
|
||||
{"10000res_80pctOwnerRefs", 10000, 80},
|
||||
|
||||
// Heavy case: all resources have owner refs
|
||||
{"1000res_100pctOwnerRefs", 1000, 100},
|
||||
{"5000res_100pctOwnerRefs", 5000, 100},
|
||||
{"10000res_100pctOwnerRefs", 10000, 100},
|
||||
|
||||
// Stress test: larger scale
|
||||
{"20000res_80pctOwnerRefs", 20000, 80},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
resources := make([]runtime.Object, 0, tc.totalResources)
|
||||
|
||||
// Create parent resources (deployments) - these won't have owner refs
|
||||
numParents := tc.totalResources / 10 // 10% are parents
|
||||
if numParents < 1 {
|
||||
numParents = 1
|
||||
}
|
||||
parentUIDs := make([]types.UID, numParents)
|
||||
for i := 0; i < numParents; i++ {
|
||||
uid := types.UID(fmt.Sprintf("deploy-uid-%d", i))
|
||||
parentUIDs[i] = uid
|
||||
resources = append(resources, &appsv1.Deployment{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("deploy-%d", i),
|
||||
Namespace: "default",
|
||||
UID: uid,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create child resources (pods) - some with owner refs
|
||||
numChildren := tc.totalResources - numParents
|
||||
numWithOwnerRefs := (numChildren * tc.pctWithOwnerRefs) / 100
|
||||
|
||||
for i := 0; i < numChildren; i++ {
|
||||
pod := &corev1.Pod{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("pod-%d", i),
|
||||
Namespace: "default",
|
||||
UID: types.UID(fmt.Sprintf("pod-uid-%d", i)),
|
||||
},
|
||||
}
|
||||
|
||||
// Add owner refs to the first numWithOwnerRefs pods
|
||||
if i < numWithOwnerRefs {
|
||||
parentIdx := i % numParents
|
||||
pod.OwnerReferences = []metav1.OwnerReference{{
|
||||
APIVersion: "apps/v1",
|
||||
Kind: "Deployment",
|
||||
Name: fmt.Sprintf("deploy-%d", parentIdx),
|
||||
UID: parentUIDs[parentIdx],
|
||||
}}
|
||||
}
|
||||
|
||||
resources = append(resources, pod)
|
||||
}
|
||||
|
||||
cluster := newCluster(b, resources...)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for n := 0; n < b.N; n++ {
|
||||
// sync() reinitializes resources, parentUIDToChildren, etc. at the start,
|
||||
// so no manual reset is needed here.
|
||||
err := cluster.sync()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkUpdateParentUIDToChildren measures the cost of incremental index updates
|
||||
// during setNode. This is called for EVERY resource during sync. The index uses
|
||||
// set-based storage so add/remove operations are O(1) regardless of children count.
|
||||
func BenchmarkUpdateParentUIDToChildren(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
childrenPerParent int
|
||||
}{
|
||||
{"10children", 10},
|
||||
{"50children", 50},
|
||||
{"100children", 100},
|
||||
{"500children", 500},
|
||||
{"1000children", 1000},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
cluster := newCluster(b)
|
||||
err := cluster.EnsureSynced()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
parentUID := types.UID("parent-uid")
|
||||
|
||||
// Pre-populate with existing children
|
||||
childrenSet := make(map[kube.ResourceKey]struct{})
|
||||
for i := 0; i < tc.childrenPerParent; i++ {
|
||||
childKey := kube.ResourceKey{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
Namespace: "default",
|
||||
Name: fmt.Sprintf("existing-child-%d", i),
|
||||
}
|
||||
childrenSet[childKey] = struct{}{}
|
||||
}
|
||||
cluster.parentUIDToChildren[parentUID] = childrenSet
|
||||
|
||||
// Create a new child key to add
|
||||
newChildKey := kube.ResourceKey{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
Namespace: "default",
|
||||
Name: "new-child",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for n := 0; n < b.N; n++ {
|
||||
// Simulate adding a new child - O(1) set insertion
|
||||
cluster.addToParentUIDToChildren(parentUID, newChildKey)
|
||||
// Remove it so we can add it again in the next iteration
|
||||
cluster.removeFromParentUIDToChildren(parentUID, newChildKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkIncrementalIndexBuild measures the cost of incremental index updates
|
||||
// via addToParentUIDToChildren during sync. The index uses O(1) set-based operations.
|
||||
//
|
||||
// This benchmark was created to investigate issue #26863 and verify the fix.
|
||||
func BenchmarkIncrementalIndexBuild(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
numParents int
|
||||
childrenPerParent int
|
||||
}{
|
||||
{"100parents_10children", 100, 10},
|
||||
{"100parents_50children", 100, 50},
|
||||
{"100parents_100children", 100, 100},
|
||||
{"1000parents_10children", 1000, 10},
|
||||
{"1000parents_100children", 1000, 100},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
// Benchmark incremental approach (what happens during setNode)
|
||||
b.Run(tc.name+"_incremental", func(b *testing.B) {
|
||||
cluster := newCluster(b)
|
||||
err := cluster.EnsureSynced()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Prepare parent UIDs and child keys
|
||||
type childInfo struct {
|
||||
parentUID types.UID
|
||||
childKey kube.ResourceKey
|
||||
}
|
||||
children := make([]childInfo, 0, tc.numParents*tc.childrenPerParent)
|
||||
for p := 0; p < tc.numParents; p++ {
|
||||
parentUID := types.UID(fmt.Sprintf("parent-%d", p))
|
||||
for c := 0; c < tc.childrenPerParent; c++ {
|
||||
children = append(children, childInfo{
|
||||
parentUID: parentUID,
|
||||
childKey: kube.ResourceKey{
|
||||
Kind: "Pod",
|
||||
Namespace: "default",
|
||||
Name: fmt.Sprintf("child-%d-%d", p, c),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for n := 0; n < b.N; n++ {
|
||||
// Clear the index
|
||||
cluster.parentUIDToChildren = make(map[types.UID]map[kube.ResourceKey]struct{})
|
||||
|
||||
// Simulate incremental adds (O(1) set insertions)
|
||||
for _, child := range children {
|
||||
cluster.addToParentUIDToChildren(child.parentUID, child.childKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -112,7 +112,7 @@ require (
|
||||
k8s.io/apimachinery v0.34.0
|
||||
k8s.io/client-go v0.34.0
|
||||
k8s.io/code-generator v0.34.0
|
||||
k8s.io/klog/v2 v2.140.0
|
||||
k8s.io/klog/v2 v2.130.1
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b
|
||||
k8s.io/kubectl v0.34.0
|
||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||
|
||||
3
go.sum
3
go.sum
@@ -1479,9 +1479,8 @@ k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJez
|
||||
k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y=
|
||||
k8s.io/klog/v2 v2.5.0/go.mod h1:hy9LJ/NvuK+iVyP4Ehqva4HxZG/oXyIS3n3Jmire4Ec=
|
||||
k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||
k8s.io/kube-aggregator v0.34.0 h1:XE4u+HOYkj0g44sblhTtPv+QyIIK7sJxrIlia0731kE=
|
||||
k8s.io/kube-aggregator v0.34.0/go.mod h1:GIUqdChXVC448Vp2Wgxf0m6fir7Xt3A2TAZcs2JNG1Y=
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
|
||||
|
||||
@@ -62,17 +62,6 @@ func SanitizeCluster(cluster *v1alpha1.Cluster) (*unstructured.Unstructured, err
|
||||
})
|
||||
}
|
||||
|
||||
func managedByURLFromAnnotations(annotations map[string]any) (string, bool) {
|
||||
managedByURL, ok := annotations[v1alpha1.AnnotationKeyManagedByURL].(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if err := settings.ValidateExternalURL(managedByURL); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return managedByURL, true
|
||||
}
|
||||
|
||||
func CreateDeepLinksObject(resourceObj *unstructured.Unstructured, app *unstructured.Unstructured, cluster *unstructured.Unstructured, project *unstructured.Unstructured) map[string]any {
|
||||
deeplinkObj := map[string]any{}
|
||||
if resourceObj != nil {
|
||||
@@ -83,10 +72,12 @@ func CreateDeepLinksObject(resourceObj *unstructured.Unstructured, app *unstruct
|
||||
deeplinkObj[AppDeepLinkShortKey] = app.Object
|
||||
|
||||
// Add managed-by URL if present in annotations
|
||||
if metadata, ok := app.Object["metadata"].(map[string]any); ok {
|
||||
if annotations, ok := metadata["annotations"].(map[string]any); ok {
|
||||
if managedByURL, ok := managedByURLFromAnnotations(annotations); ok {
|
||||
deeplinkObj[ManagedByURLKey] = managedByURL
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,29 +237,6 @@ func TestManagedByURLAnnotation(t *testing.T) {
|
||||
assert.Equal(t, managedByURL, deeplinksObj[ManagedByURLKey])
|
||||
})
|
||||
|
||||
t.Run("application with invalid managed-by-url annotation is omitted", func(t *testing.T) {
|
||||
// Non http(s) protocols are invalid and should not be used in deep link generation.
|
||||
managedByURL := "ftp://localhost:8081"
|
||||
|
||||
app := &v1alpha1.Application{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-app",
|
||||
Annotations: map[string]string{
|
||||
v1alpha1.AnnotationKeyManagedByURL: managedByURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(app)
|
||||
require.NoError(t, err)
|
||||
unstructuredObj := &unstructured.Unstructured{Object: obj}
|
||||
|
||||
deeplinksObj := CreateDeepLinksObject(nil, unstructuredObj, nil, nil)
|
||||
|
||||
_, exists := deeplinksObj[ManagedByURLKey]
|
||||
assert.False(t, exists)
|
||||
})
|
||||
|
||||
t.Run("application without managed-by-url annotation", func(t *testing.T) {
|
||||
// Create an application without managed-by-url annotation
|
||||
app := &v1alpha1.Application{
|
||||
|
||||
@@ -12,16 +12,13 @@ import {
|
||||
createdOrNodeKey,
|
||||
resourceStatusToResourceNode,
|
||||
getApplicationLinkURLFromNode,
|
||||
getManagedByURLFromNode,
|
||||
MANAGED_BY_URL_INVALID_TEXT,
|
||||
MANAGED_BY_URL_INVALID_COLOR
|
||||
getManagedByURLFromNode
|
||||
} from '../utils';
|
||||
import {AppDetailsPreferences} from '../../../shared/services';
|
||||
import {Consumer} from '../../../shared/context';
|
||||
import Moment from 'react-moment';
|
||||
import {format} from 'date-fns';
|
||||
import {HealthPriority, ResourceNode, SyncPriority, SyncStatusCode} from '../../../shared/models';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
import './application-resource-list.scss';
|
||||
|
||||
export interface ApplicationResourceListProps {
|
||||
@@ -204,20 +201,6 @@ export const ApplicationResourceList = (props: ApplicationResourceListProps) =>
|
||||
? getApplicationLinkURLFromNode(node, ctx.baseHref)
|
||||
: {url: ctx.baseHref + 'applications/' + res.namespace + '/' + res.name, isExternal: false};
|
||||
const managedByURL = node ? getManagedByURLFromNode(node) : null;
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
if (managedByURLInvalid) {
|
||||
return (
|
||||
<span
|
||||
className='application-details__external_link'
|
||||
style={{cursor: 'not-allowed', display: 'inline-flex', alignItems: 'center'}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={`Open application\n${MANAGED_BY_URL_INVALID_TEXT}`}>
|
||||
<i className='fa fa-external-link-alt' style={{color: MANAGED_BY_URL_INVALID_COLOR}} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className='application-details__external_link'>
|
||||
<a
|
||||
|
||||
@@ -6,7 +6,6 @@ import Moment from 'react-moment';
|
||||
import * as moment from 'moment';
|
||||
|
||||
import * as models from '../../../shared/models';
|
||||
import {isValidManagedByURL, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_COLOR} from '../../../shared/utils';
|
||||
|
||||
import {EmptyState} from '../../../shared/components';
|
||||
import {AppContext, Consumer} from '../../../shared/context';
|
||||
@@ -497,20 +496,6 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R
|
||||
{ctx => {
|
||||
// For nested applications, use the node's data to construct the URL
|
||||
const linkInfo = getApplicationLinkURLFromNode(node, ctx.baseHref);
|
||||
const managedByURL = getManagedByURLFromNode(node);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
if (managedByURLInvalid) {
|
||||
return (
|
||||
<span
|
||||
role='link'
|
||||
aria-disabled={true}
|
||||
style={{cursor: 'not-allowed', display: 'inline-flex', alignItems: 'center'}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title={`Open application\n${MANAGED_BY_URL_INVALID_TEXT}`}>
|
||||
<i className='fa fa-external-link-alt' style={{color: MANAGED_BY_URL_INVALID_COLOR}} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={linkInfo.url}
|
||||
@@ -519,7 +504,7 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={managedByURL ? `Open application\nmanaged-by-url: ${managedByURL}` : 'Open application'}>
|
||||
title={getManagedByURLFromNode(node) ? `Open application\nmanaged-by-url: ${getManagedByURLFromNode(node)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</a>
|
||||
);
|
||||
@@ -821,20 +806,6 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
|
||||
{ctx => {
|
||||
// For nested applications, use the node's data to construct the URL
|
||||
const linkInfo = getApplicationLinkURLFromNode(node, ctx.baseHref);
|
||||
const managedByURL = getManagedByURLFromNode(node);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
if (managedByURLInvalid) {
|
||||
return (
|
||||
<span
|
||||
role='link'
|
||||
aria-disabled={true}
|
||||
style={{cursor: 'not-allowed', display: 'inline-flex', alignItems: 'center'}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
title={`Open application\n${MANAGED_BY_URL_INVALID_TEXT}`}>
|
||||
<i className='fa fa-external-link-alt' style={{color: MANAGED_BY_URL_INVALID_COLOR}} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={linkInfo.url}
|
||||
@@ -843,7 +814,7 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={managedByURL ? `Open application\nmanaged-by-url: ${managedByURL}` : 'Open application'}>
|
||||
title={getManagedByURLFromNode(node) ? `Open application\nmanaged-by-url: ${getManagedByURLFromNode(node)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {DropDownMenu, NotificationType, Tooltip} from 'argo-ui';
|
||||
import {DropDownMenu, Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import {Cluster} from '../../../shared/components';
|
||||
@@ -6,8 +6,7 @@ import {ContextApis} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import {ApplicationURLs} from '../application-urls';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {ApplicationsLabels} from './applications-labels';
|
||||
import {ApplicationsSource} from './applications-source';
|
||||
import {services} from '../../../shared/services';
|
||||
@@ -28,8 +27,6 @@ export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication,
|
||||
const healthStatus = app.status.health.status;
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
const source = getAppDefaultSource(app);
|
||||
const managedByURL = getManagedByURL(app);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -43,18 +40,6 @@ export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication,
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -107,11 +92,9 @@ export const ApplicationTableRow = ({app, selected, pref, ctx, syncApplication,
|
||||
<span>{app.metadata.name}</span>
|
||||
</Tooltip>
|
||||
<button
|
||||
type='button'
|
||||
className={managedByURLInvalid ? 'managed-by-url-invalid' : undefined}
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{marginLeft: '0.5em', cursor: managedByURLInvalid ? 'not-allowed' : undefined}}
|
||||
title={managedByURLInvalid ? MANAGED_BY_URL_INVALID_TEXT : `Link: ${linkInfo.url}\nmanaged-by-url: ${managedByURL || 'none'}`}>
|
||||
style={{marginLeft: '0.5em'}}
|
||||
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(app) || 'none'}`}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {NotificationType, Tooltip} from 'argo-ui';
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import * as classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
import {Cluster} from '../../../shared/components';
|
||||
@@ -6,8 +6,7 @@ import {ContextApis, AuthSettingsCtx} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import {ApplicationURLs} from '../application-urls';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
|
||||
@@ -31,8 +30,6 @@ export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplicat
|
||||
const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
|
||||
const linkInfo = getApplicationLinkURL(app, ctx.baseHref);
|
||||
const healthStatus = app.status.health.status;
|
||||
const managedByURL = getManagedByURL(app);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -46,18 +43,6 @@ export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplicat
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -82,20 +67,9 @@ export const ApplicationTile = ({app, selected, pref, ctx, tileRef, syncApplicat
|
||||
<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} />
|
||||
{managedByURLInvalid ? (
|
||||
<button
|
||||
type='button'
|
||||
className='managed-by-url-invalid'
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{cursor: 'not-allowed'}}
|
||||
title={MANAGED_BY_URL_INVALID_TEXT}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
) : (
|
||||
<button type='button' onClick={handleExternalLinkClick} title={managedByURL ? `Managed by: ${managedByURL}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleExternalLinkClick} title={getManagedByURL(app) ? `Managed by: ${getManagedByURL(app)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
<button
|
||||
title={favList?.includes(app.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
|
||||
className='large-text-height'
|
||||
|
||||
@@ -39,36 +39,28 @@
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.applications-list__table-row button.managed-by-url-invalid {
|
||||
color: #f4c030;
|
||||
|
||||
.applications-list__external-link {
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
color: #f4c030;
|
||||
color: $argo-color-teal-5;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,26 +70,9 @@
|
||||
&:hover {
|
||||
color: $argo-color-teal-5;
|
||||
}
|
||||
|
||||
&.managed-by-url-invalid {
|
||||
color: #f4c030;
|
||||
|
||||
&:hover {
|
||||
color: #f4c030;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Table / name column external-link (not under .applications-list__external-link) */
|
||||
.applications-list__table-row button.managed-by-url-invalid {
|
||||
color: #f4c030;
|
||||
|
||||
&:hover {
|
||||
color: #f4c030;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {NotificationType, Tooltip} from 'argo-ui';
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import Moment from 'react-moment';
|
||||
import {ContextApis} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
|
||||
export interface AppSetTableRowProps {
|
||||
appSet: models.ApplicationSet;
|
||||
@@ -20,8 +19,6 @@ export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProp
|
||||
const favList = pref.appList.favoritesAppList || [];
|
||||
const healthStatus = getAppSetHealthStatus(appSet);
|
||||
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
|
||||
const managedByURL = getManagedByURL(appSet);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -35,18 +32,6 @@ export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProp
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -96,11 +81,9 @@ export const AppSetTableRow = ({appSet, selected, pref, ctx}: AppSetTableRowProp
|
||||
<span>{appSet.metadata.name}</span>
|
||||
</Tooltip>
|
||||
<button
|
||||
type='button'
|
||||
className={managedByURLInvalid ? 'managed-by-url-invalid' : undefined}
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{marginLeft: '0.5em', cursor: managedByURLInvalid ? 'not-allowed' : undefined}}
|
||||
title={managedByURLInvalid ? MANAGED_BY_URL_INVALID_TEXT : `Link: ${linkInfo.url}\nmanaged-by-url: ${managedByURL || 'none'}`}>
|
||||
style={{marginLeft: '0.5em'}}
|
||||
title={`Link: ${linkInfo.url}\nmanaged-by-url: ${getManagedByURL(appSet) || 'none'}`}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {NotificationType, Tooltip} from 'argo-ui';
|
||||
import {Tooltip} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import {ContextApis, AuthSettingsCtx} from '../../../shared/context';
|
||||
import * as models from '../../../shared/models';
|
||||
import * as AppUtils from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus, MANAGED_BY_URL_INVALID_TEXT, MANAGED_BY_URL_INVALID_TOOLTIP} from '../utils';
|
||||
import {getApplicationLinkURL, getManagedByURL, getAppSetHealthStatus} from '../utils';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ViewPreferences} from '../../../shared/services';
|
||||
import {ResourceIcon} from '../resource-icon';
|
||||
import {isValidManagedByURL} from '../../../shared/utils';
|
||||
|
||||
export interface AppSetTileProps {
|
||||
appSet: models.ApplicationSet;
|
||||
@@ -23,8 +22,6 @@ export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTilePro
|
||||
|
||||
const linkInfo = getApplicationLinkURL(appSet, ctx.baseHref);
|
||||
const healthStatus = getAppSetHealthStatus(appSet);
|
||||
const managedByURL = getManagedByURL(appSet);
|
||||
const managedByURLInvalid = !!managedByURL && !isValidManagedByURL(managedByURL);
|
||||
|
||||
const handleFavoriteToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -38,18 +35,6 @@ export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTilePro
|
||||
|
||||
const handleExternalLinkClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (managedByURLInvalid) {
|
||||
ctx.notifications.show({
|
||||
content: (
|
||||
<div>
|
||||
<div style={{fontWeight: 600}}>{MANAGED_BY_URL_INVALID_TEXT}</div>
|
||||
<div style={{marginTop: 6}}>{MANAGED_BY_URL_INVALID_TOOLTIP}</div>
|
||||
</div>
|
||||
),
|
||||
type: NotificationType.Warning
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (linkInfo.isExternal) {
|
||||
window.open(linkInfo.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
@@ -73,20 +58,9 @@ export const AppSetTile = ({appSet, selected, pref, ctx, tileRef}: AppSetTilePro
|
||||
</div>
|
||||
<div className='columns small-1'>
|
||||
<div className='applications-list__external-link'>
|
||||
{managedByURLInvalid ? (
|
||||
<button
|
||||
type='button'
|
||||
className='managed-by-url-invalid'
|
||||
onClick={handleExternalLinkClick}
|
||||
style={{cursor: 'not-allowed'}}
|
||||
title={MANAGED_BY_URL_INVALID_TEXT}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
) : (
|
||||
<button type='button' onClick={handleExternalLinkClick} title={managedByURL ? `Managed by: ${managedByURL}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleExternalLinkClick} title={getManagedByURL(appSet) ? `Managed by: ${getManagedByURL(appSet)}` : 'Open application'}>
|
||||
<i className='fa fa-external-link-alt' />
|
||||
</button>
|
||||
<button
|
||||
title={favList?.includes(appSet.metadata.name) ? 'Remove Favorite' : 'Add Favorite'}
|
||||
className='large-text-height'
|
||||
|
||||
@@ -8,7 +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 {isValidManagedByURL} from '../../shared/utils';
|
||||
import {isValidURL} from '../../shared/utils';
|
||||
import {ResourceTreeNode} from './application-resource-tree/application-resource-tree';
|
||||
|
||||
import {CheckboxField, COLORS, ErrorNotification, Revision} from '../../shared/components';
|
||||
@@ -18,14 +18,6 @@ import {ApplicationSource} from '../../shared/models';
|
||||
|
||||
require('./utils.scss');
|
||||
|
||||
export {
|
||||
MANAGED_BY_URL_INVALID_COLOR,
|
||||
MANAGED_BY_URL_INVALID_TEXT,
|
||||
MANAGED_BY_URL_INVALID_TOOLTIP,
|
||||
managedByURLInvalidLabelStyle,
|
||||
managedByURLInvalidLabelStyleCompact
|
||||
} from '../../shared/utils';
|
||||
|
||||
export interface NodeId {
|
||||
kind: string;
|
||||
namespace: string;
|
||||
@@ -1998,7 +1990,7 @@ export function getApplicationLinkURL(app: any, baseHref: string, node?: any): {
|
||||
let url, isExternal;
|
||||
if (managedByURL) {
|
||||
// Validate the managed-by URL using the same validation as external links
|
||||
if (!isValidManagedByURL(managedByURL)) {
|
||||
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;
|
||||
@@ -2026,7 +2018,7 @@ export function getApplicationLinkURLFromNode(node: any, baseHref: string): {url
|
||||
let url, isExternal;
|
||||
if (managedByURL) {
|
||||
// Validate the managed-by URL using the same validation as external links
|
||||
if (!isValidManagedByURL(managedByURL)) {
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/* eslint-env jest */
|
||||
declare const test: any;
|
||||
declare const expect: any;
|
||||
declare const describe: any;
|
||||
import {concatMaps} from './utils';
|
||||
import {isValidManagedByURL} from './utils';
|
||||
|
||||
test('map concatenation', () => {
|
||||
const map1 = {
|
||||
@@ -17,24 +12,3 @@ test('map concatenation', () => {
|
||||
const map3 = concatMaps(map1, map2);
|
||||
expect(map3).toEqual(new Map(Object.entries({a: '9', b: '2', c: '8'})));
|
||||
});
|
||||
|
||||
describe('isValidManagedByURL', () => {
|
||||
test('accepts http/https URLs', () => {
|
||||
expect(isValidManagedByURL('http://example.com')).toBe(true);
|
||||
expect(isValidManagedByURL('https://example.com')).toBe(true);
|
||||
expect(isValidManagedByURL('https://localhost:8081')).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects non-http(s) protocols', () => {
|
||||
expect(isValidManagedByURL('ftp://localhost:8081')).toBe(false);
|
||||
expect(isValidManagedByURL('file:///etc/passwd')).toBe(false);
|
||||
expect(isValidManagedByURL('javascript:alert(1)')).toBe(false);
|
||||
expect(isValidManagedByURL('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
expect(isValidManagedByURL('vbscript:msgbox(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects invalid URL strings', () => {
|
||||
expect(isValidManagedByURL('not-a-url')).toBe(false);
|
||||
expect(isValidManagedByURL('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import type {CSSProperties} from 'react';
|
||||
import React from 'react';
|
||||
import {Cluster} from './models';
|
||||
|
||||
export function hashCode(str: string) {
|
||||
@@ -39,38 +38,6 @@ export function isValidURL(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// managed-by-url is expected to mostly if not always point to another Argo CD instance URL,
|
||||
// so we only consider http/https valid for click-through behavior.
|
||||
export function isValidManagedByURL(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const MANAGED_BY_URL_INVALID_TEXT = 'managed-by-url: invalid url provided';
|
||||
export const MANAGED_BY_URL_INVALID_TOOLTIP = 'managed-by-url must be a valid http(s) URL for the managing Argo CD instance. The external link is disabled until this is fixed.';
|
||||
|
||||
export const MANAGED_BY_URL_INVALID_COLOR = '#f4c030';
|
||||
|
||||
export const managedByURLInvalidLabelStyle: CSSProperties = {
|
||||
color: MANAGED_BY_URL_INVALID_COLOR,
|
||||
marginLeft: '0.5em',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.35,
|
||||
whiteSpace: 'nowrap'
|
||||
};
|
||||
|
||||
export const managedByURLInvalidLabelStyleCompact: CSSProperties = {
|
||||
...managedByURLInvalidLabelStyle,
|
||||
marginLeft: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600
|
||||
};
|
||||
|
||||
export const colorSchemes = {
|
||||
light: '(prefers-color-scheme: light)',
|
||||
dark: '(prefers-color-scheme: dark)'
|
||||
@@ -114,9 +81,9 @@ export const useSystemTheme = (cb: (theme: string) => void) => {
|
||||
};
|
||||
|
||||
export const useTheme = (props: {theme: string}) => {
|
||||
const [theme, setTheme] = useState(getTheme(props.theme));
|
||||
const [theme, setTheme] = React.useState(getTheme(props.theme));
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
let destroyListener: (() => void) | undefined;
|
||||
|
||||
// change theme by system, only register listener when theme is auto
|
||||
|
||||
@@ -207,9 +207,6 @@ func SetLogLevel(logLevel string) {
|
||||
// SetGLogLevel set the glog level for the k8s go-client
|
||||
func SetGLogLevel(glogLevel int) {
|
||||
klog.InitFlags(nil)
|
||||
// Opt into fixed stderrthreshold behavior (kubernetes/klog#212).
|
||||
_ = flag.Set("legacy_stderr_threshold_behavior", "false")
|
||||
_ = flag.Set("stderrthreshold", "INFO")
|
||||
_ = flag.Set("logtostderr", "true")
|
||||
_ = flag.Set("v", strconv.Itoa(glogLevel))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user