feat: optionally propagate node labels to application pod view (#15274) (#23260)

Signed-off-by: Marcus Söderberg <msoderb@gmail.com>
Co-authored-by: Linghao Su <slh001@live.cn>
This commit is contained in:
Marcus Söderberg
2025-06-13 17:19:27 +02:00
committed by GitHub
parent f4edcf7717
commit 00ee32f7f5
18 changed files with 1100 additions and 772 deletions

7
assets/swagger.json generated
View File

@@ -8325,6 +8325,13 @@
"description": "HostInfo holds metadata and resource usage metrics for a specific host in the cluster.",
"type": "object",
"properties": {
"labels": {
"description": "Labels holds the labels attached to the host.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"name": {
"description": "Name is the hostname or node name in the Kubernetes cluster.",
"type": "string"

View File

@@ -774,7 +774,16 @@ func (ctrl *ApplicationController) getAppHosts(destCluster *appv1.Cluster, a *ap
sort.Slice(resourcesInfo, func(i, j int) bool {
return resourcesInfo[i].ResourceName < resourcesInfo[j].ResourceName
})
hosts = append(hosts, appv1.HostInfo{Name: nodeName, SystemInfo: node.SystemInfo, ResourcesInfo: resourcesInfo})
allowedNodeLabels := ctrl.settingsMgr.GetAllowedNodeLabels()
nodeLabels := make(map[string]string)
for _, label := range allowedNodeLabels {
if val, ok := node.Labels[label]; ok {
nodeLabels[label] = val
}
}
hosts = append(hosts, appv1.HostInfo{Name: nodeName, SystemInfo: node.SystemInfo, ResourcesInfo: resourcesInfo, Labels: nodeLabels})
}
ts.AddCheckpoint("process_app_pods_by_node_ms")
return hosts, nil

View File

@@ -2203,6 +2203,9 @@ func TestGetAppHosts(t *testing.T) {
Server: test.FakeClusterURL,
Revision: "abc123",
},
configMapData: map[string]string{
"application.allowedNodeLabels": "label1,label2",
},
}
ctrl := newFakeController(data, nil)
mockStateCache := &mockstatecache.LiveStateCache{}
@@ -2214,6 +2217,7 @@ func TestGetAppHosts(t *testing.T) {
Name: "minikube",
SystemInfo: corev1.NodeSystemInfo{OSImage: "debian"},
Capacity: map[corev1.ResourceName]resource.Quantity{corev1.ResourceCPU: resource.MustParse("5")},
Labels: map[string]string{"label1": "value1", "label2": "value2"},
}})
// app pod
@@ -2251,6 +2255,7 @@ func TestGetAppHosts(t *testing.T) {
ResourceName: corev1.ResourceCPU, Capacity: 5000, RequestedByApp: 1000, RequestedByNeighbors: 2000,
},
},
Labels: map[string]string{"label1": "value1", "label2": "value2"},
}}, hosts)
}

View File

@@ -169,6 +169,7 @@ type NodeInfo struct {
Name string
Capacity corev1.ResourceList
SystemInfo corev1.NodeSystemInfo
Labels map[string]string
}
type ResourceInfo struct {

View File

@@ -472,6 +472,7 @@ func populateHostNodeInfo(un *unstructured.Unstructured, res *ResourceInfo) {
Name: node.Name,
Capacity: node.Status.Capacity,
SystemInfo: node.Status.NodeInfo,
Labels: node.Labels,
}
}

View File

@@ -890,6 +890,8 @@ apiVersion: v1
kind: Node
metadata:
name: minikube
labels:
foo: bar
spec: {}
status:
capacity:
@@ -907,6 +909,7 @@ status:
Name: "minikube",
Capacity: corev1.ResourceList{corev1.ResourceMemory: resource.MustParse("6091320Ki"), corev1.ResourceCPU: resource.MustParse("6")},
SystemInfo: corev1.NodeSystemInfo{Architecture: "amd64", OperatingSystem: "linux", OSImage: "Ubuntu 20.04 LTS"},
Labels: map[string]string{"foo": "bar"},
}, info.NodeInfo)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -277,6 +277,9 @@ data:
# If omitted, Argo CD injects the app name into the label: 'app.kubernetes.io/instance'
application.instanceLabelKey: mycompany.com/appname
# An optional comma-separated list of node labels to propagate to the application pod view.
application.allowedNodeLabels: topology.kubernetes.io/zone,node.kubernetes.io/instance-type
# You can change the resource tracking method Argo CD uses by changing the
# setting application.resourceTrackingMethod to the desired method.
# The following methods are available:

View File

@@ -6,4 +6,15 @@ By default, the Application Details will show the `Tree` view.
This can be configured on an Application basis, by setting the `pref.argocd.argoproj.io/default-view` annotation, accepting one of: `tree`, `pods`, `network`, `list` as values.
For the Pods view, the default grouping mechanism can be configured using the `pref.argocd.argoproj.io/default-pod-sort` annotation, accepting one of: `node`, `parentResource`, `topLevelResource` as values.
For the Pods view, the default grouping mechanism can be configured using the `pref.argocd.argoproj.io/default-pod-sort` annotation, accepting one of: `node`, `parentResource`, `topLevelResource` as values.
## Node Labels in Pod View
It's possible to propagate node labels to node information in the pod view by configuring `applications.allowedNodeLabels` in the [argocd-cm](argocd-cm-yaml.md) ConfigMap.
The following configuration:
```yaml
application.allowedNodeLabels: topology.kubernetes.io/zone,karpenter.sh/capacity-type
```
Would result in:
![Node Labels in Pod View](../assets/application-pod-view-node-labels.png)

File diff suppressed because it is too large Load Diff

View File

@@ -1161,6 +1161,9 @@ message HostInfo {
// SystemInfo contains detailed system-level information about the host, such as OS, kernel version, and architecture.
optional .k8s.io.api.core.v1.NodeSystemInfo systemInfo = 3;
// Labels holds the labels attached to the host.
map<string, string> labels = 4;
}
// HostResourceInfo represents resource usage details for a specific resource type on a host.

View File

@@ -1870,6 +1870,8 @@ type HostInfo struct {
ResourcesInfo []HostResourceInfo `json:"resourcesInfo,omitempty" protobuf:"bytes,2,name=resourcesInfo"`
// SystemInfo contains detailed system-level information about the host, such as OS, kernel version, and architecture.
SystemInfo corev1.NodeSystemInfo `json:"systemInfo,omitempty" protobuf:"bytes,3,opt,name=systemInfo"`
// Labels holds the labels attached to the host.
Labels map[string]string `json:"labels,omitempty" protobuf:"bytes,4,opt,name=labels"`
}
// ApplicationTree represents the hierarchical structure of resources associated with an Argo CD application.

View File

@@ -2255,6 +2255,13 @@ func (in *HostInfo) DeepCopyInto(out *HostInfo) {
copy(*out, *in)
}
out.SystemInfo = in.SystemInfo
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
return
}

View File

@@ -43,6 +43,9 @@ $pod-age-icon-clr: #ffce25;
margin: 10px;
margin-bottom: 14px !important;
width: $pod-container-width + 2 * $padding;
display: flex;
flex-direction: column;
justify-content: space-between;
&--large {
width: $pod-container-width + (2 * $padding) + ($num-stats * ($stat-width + 2 * $gutter)) + 6 * $gutter;
@@ -52,6 +55,7 @@ $pod-age-icon-clr: #ffce25;
&--header {
align-items: center;
margin-bottom: 1em;
height: 100%;
}
&--stats {
margin-left: -2 * $gutter;
@@ -76,11 +80,25 @@ $pod-age-icon-clr: #ffce25;
border-radius: 3px;
background-color: $argo-color-gray-3;
color: $argo-color-gray-6;
div {
max-height: 100px;
overflow: auto;
&__item {
line-height: 20px;
display: flex;
& div:last-child {
&__name {
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__value {
font-weight: 500;
margin-left: auto;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}

View File

@@ -42,6 +42,7 @@ export interface PodGroup extends Partial<ResourceNode> {
renderMenu?: () => React.ReactNode;
renderQuickStarts?: () => React.ReactNode;
fullName?: string;
hostLabels?: {[name: string]: string};
}
export class PodView extends React.Component<PodViewProps> {
@@ -162,12 +163,29 @@ export class PodView extends React.Component<PodViewProps> {
</div>
</div>
{group.type === 'node' ? (
<div className='pod-view__node__info--large'>
{(group.info || []).map(item => (
<div key={item.name}>
{item.name}: <div>{item.value}</div>
<div>
<div className='pod-view__node__info--large'>
{(group.info || []).map(item => (
<Tooltip content={`${item.name}: ${item.value}`} key={item.name}>
<div className='pod-view__node__info--large__item'>
<div className='pod-view__node__info--large__item__name'>{item.name}:</div>
<div className='pod-view__node__info--large__item__value'>{item.value}</div>
</div>
</Tooltip>
))}
</div>
{group.hostLabels && Object.keys(group.hostLabels).length > 0 ? (
<div className='pod-view__node__info--large'>
{Object.keys(group.hostLabels || []).map(label => (
<Tooltip content={`${label}: ${group.hostLabels[label]}`} key={label}>
<div className='pod-view__node__info--large__item'>
<div className='pod-view__node__info--large__item__name'>{label}:</div>
<div className='pod-view__node__info--large__item__value'>{group.hostLabels[label]}</div>
</div>
</Tooltip>
))}
</div>
))}
) : null}
</div>
) : (
<div className='pod-view__node__info'>
@@ -307,7 +325,8 @@ export class PodView extends React.Component<PodViewProps> {
{name: 'Kernel Version', value: infraNode.systemInfo.kernelVersion},
{name: 'OS/Arch', value: `${infraNode.systemInfo.operatingSystem}/${infraNode.systemInfo.architecture}`}
],
hostResourcesInfo: infraNode.resourcesInfo
hostResourcesInfo: infraNode.resourcesInfo,
hostLabels: infraNode.labels
};
});
}
@@ -377,7 +396,8 @@ export class PodView extends React.Component<PodViewProps> {
{name: 'Kernel Version', value: 'N/A'},
{name: 'OS/Arch', value: 'N/A'}
],
hostResourcesInfo: []
hostResourcesInfo: [],
hostLabels: {}
};
}
}

View File

@@ -1005,6 +1005,7 @@ export interface Node {
name: string;
systemInfo: NodeSystemInfo;
resourcesInfo: HostResourceInfo[];
labels: {[name: string]: string};
}
export interface NodeSystemInfo {

View File

@@ -25,6 +25,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/validation"
informersv1 "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/kubernetes"
v1listers "k8s.io/client-go/listers/core/v1"
@@ -455,6 +456,8 @@ const (
settingsApplicationInstanceLabelKey = "application.instanceLabelKey"
// settingsResourceTrackingMethodKey is the key to configure tracking method for application resources
settingsResourceTrackingMethodKey = "application.resourceTrackingMethod"
// allowedNodeLabelsKey is the key to the list of allowed node labels for the application pod view
allowedNodeLabelsKey = "application.allowedNodeLabels"
// settingsInstallationID holds the key for the instance installation ID
settingsInstallationID = "installationID"
// resourcesCustomizationsKey is the key to the map of resource overrides
@@ -2286,3 +2289,26 @@ func (mgr *SettingsManager) IsImpersonationEnabled() (bool, error) {
}
return cm.Data[impersonationEnabledKey] == "true", nil
}
func (mgr *SettingsManager) GetAllowedNodeLabels() []string {
labelKeys := []string{}
argoCDCM, err := mgr.getConfigMap()
if err != nil {
log.Error(fmt.Errorf("failed getting allowedNodeLabels from configmap: %w", err))
return labelKeys
}
value, ok := argoCDCM.Data[allowedNodeLabelsKey]
if !ok || value == "" {
return labelKeys
}
value = strings.ReplaceAll(value, " ", "")
keys := strings.SplitSeq(value, ",")
for k := range keys {
if errs := validation.IsQualifiedName(k); len(errs) > 0 {
log.Warnf("Invalid node label key '%s' in configmap: %v", k, errs)
continue
}
labelKeys = append(labelKeys, k)
}
return labelKeys
}

View File

@@ -1893,3 +1893,42 @@ func TestSettingsManager_GetHideSecretAnnotations(t *testing.T) {
})
}
}
func TestSettingsManager_GetAllowedNodeLabels(t *testing.T) {
tests := []struct {
name string
input string
output []string
}{
{
name: "Empty input",
input: "",
output: []string{},
},
{
name: "Comma separated data",
input: "example.com/label,label1,label2",
output: []string{"example.com/label", "label1", "label2"},
},
{
name: "Comma separated data with space",
input: "example.com/label, label1, label2",
output: []string{"example.com/label", "label1", "label2"},
},
{
name: "Comma separated data with invalid label",
input: "example.com/label,_invalid,label1,label2",
output: []string{"example.com/label", "label1", "label2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, settingsManager := fixtures(map[string]string{
allowedNodeLabelsKey: tt.input,
})
keys := settingsManager.GetAllowedNodeLabels()
assert.Len(t, keys, len(tt.output))
assert.Equal(t, tt.output, keys)
})
}
}