mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
fix: display unscheduled applications (#496)
Co-authored-by: JamesPlayer <james.player@portainer.io>
This commit is contained in:
parent
b5961d79f8
commit
8b7aef883a
18 changed files with 796 additions and 201 deletions
461
api/kubernetes/cli/applications_test.go
Normal file
461
api/kubernetes/cli/applications_test.go
Normal file
|
@ -0,0 +1,461 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// Helper functions to create test resources
|
||||
func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("deploy-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "nginx:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.DeploymentStatus{
|
||||
Replicas: replicas,
|
||||
ReadyReplicas: replicas,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
|
||||
return &appsv1.ReplicaSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("rs-" + name),
|
||||
OwnerReferences: []metav1.OwnerReference{
|
||||
{
|
||||
Kind: "Deployment",
|
||||
Name: deploymentName,
|
||||
UID: types.UID("deploy-" + deploymentName),
|
||||
},
|
||||
},
|
||||
},
|
||||
Spec: appsv1.ReplicaSetSpec{
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": deploymentName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
|
||||
return &appsv1.StatefulSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("sts-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "redis:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.StatefulSetStatus{
|
||||
Replicas: replicas,
|
||||
ReadyReplicas: replicas,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
|
||||
return &appsv1.DaemonSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("ds-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DaemonSetSpec{
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "fluentd:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.DaemonSetStatus{
|
||||
DesiredNumberScheduled: 2,
|
||||
NumberReady: 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
|
||||
phase := corev1.PodPending
|
||||
if isRunning {
|
||||
phase = corev1.PodRunning
|
||||
}
|
||||
|
||||
var ownerReferences []metav1.OwnerReference
|
||||
if ownerKind != "" && ownerName != "" {
|
||||
ownerReferences = []metav1.OwnerReference{
|
||||
{
|
||||
Kind: ownerKind,
|
||||
Name: ownerName,
|
||||
UID: types.UID(ownerKind + "-" + ownerName),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("pod-" + name),
|
||||
OwnerReferences: ownerReferences,
|
||||
Labels: map[string]string{
|
||||
"app": ownerName,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "container-" + name,
|
||||
Image: "busybox:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1.PodStatus{
|
||||
Phase: phase,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("svc-" + name),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: selector,
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApplications(t *testing.T) {
|
||||
t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup the test namespace
|
||||
namespace := "test-namespace"
|
||||
defaultNamespace := "default"
|
||||
|
||||
// Create resources in the test namespace
|
||||
// 1. Deployment with pods
|
||||
deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
|
||||
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
|
||||
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 2. Deployment without pods (scaled to 0)
|
||||
deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
|
||||
_, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 3. StatefulSet with pods
|
||||
stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 4. StatefulSet without pods
|
||||
stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 5. DaemonSet with pods
|
||||
dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
|
||||
_, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 6. Naked Pod (no owner reference)
|
||||
nakedPod := createTestPod("naked-pod", namespace, "", "", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 7. Resources in another namespace
|
||||
deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
|
||||
_, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
|
||||
_, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 8. Add a service (dependency)
|
||||
service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
|
||||
_, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient with admin privileges
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: true,
|
||||
}
|
||||
|
||||
// Test cases
|
||||
|
||||
// 1. All resources, no filtering
|
||||
t.Run("All resources with dependencies", func(t *testing.T) {
|
||||
apps, err := kubeClient.GetApplications("", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
|
||||
// Note: Each controller with pods should count once, not per pod
|
||||
assert.Equal(t, 7, len(apps))
|
||||
|
||||
// Verify one of the deployments has services attached
|
||||
appsWithServices := []models.K8sApplication{}
|
||||
for _, app := range apps {
|
||||
if len(app.Services) > 0 {
|
||||
appsWithServices = append(appsWithServices, app)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, len(appsWithServices))
|
||||
assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
|
||||
})
|
||||
|
||||
// 2. Filter by namespace
|
||||
t.Run("Filter by namespace", func(t *testing.T) {
|
||||
apps, err := kubeClient.GetApplications(namespace, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 6 resources in the test namespace
|
||||
assert.Equal(t, 6, len(apps))
|
||||
|
||||
// Verify resources from other namespaces are not included
|
||||
for _, app := range apps {
|
||||
assert.Equal(t, namespace, app.ResourcePool)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup the test namespaces
|
||||
namespace1 := "allowed-ns"
|
||||
namespace2 := "restricted-ns"
|
||||
|
||||
// Create resources in the allowed namespace
|
||||
sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
|
||||
_, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Add a StatefulSet without pods in the allowed namespace
|
||||
stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create resources in the restricted namespace
|
||||
sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient with non-admin privileges (only allowed namespace1)
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: false,
|
||||
NonAdminNamespaces: []string{namespace1},
|
||||
}
|
||||
|
||||
// Test that only resources from allowed namespace are returned
|
||||
apps, err := kubeClient.GetApplications("", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
|
||||
assert.Equal(t, 2, len(apps))
|
||||
|
||||
// Verify resources are from the allowed namespace
|
||||
for _, app := range apps {
|
||||
assert.Equal(t, namespace1, app.ResourcePool)
|
||||
assert.Equal(t, "StatefulSet", app.Kind)
|
||||
}
|
||||
|
||||
// Verify names of returned resources
|
||||
stsNames := make(map[string]bool)
|
||||
for _, app := range apps {
|
||||
stsNames[app.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
|
||||
assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
|
||||
})
|
||||
|
||||
t.Run("Filter by node name", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup test namespace
|
||||
namespace := "node-filter-ns"
|
||||
nodeName := "worker-node-1"
|
||||
|
||||
// Create a deployment with pods on specific node
|
||||
deploy := createTestDeployment("node-deploy", namespace, 2)
|
||||
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create ReplicaSet for the deployment
|
||||
rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
|
||||
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create 2 pods, one on the specified node, one on a different node
|
||||
pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||
pod1.Spec.NodeName = nodeName
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||
pod2.Spec.NodeName = "worker-node-2"
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: true,
|
||||
}
|
||||
|
||||
// Test filtering by node name
|
||||
apps, err := kubeClient.GetApplications(namespace, nodeName)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect to find only the pod on the specified node
|
||||
assert.Equal(t, 1, len(apps))
|
||||
if len(apps) > 0 {
|
||||
assert.Equal(t, "node-deploy", apps[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue