fix(kubernetes): improve PVC deletion UX based on workload usage [R8S-1046] (#2766)

This commit is contained in:
Steven Kang
2026-06-03 09:43:07 +12:00
committed by GitHub
parent 86a84c3c6a
commit eb5ee3bfdb
9 changed files with 460 additions and 3 deletions
@@ -44,6 +44,11 @@ func (handler *Handler) getAllKubernetesPersistentVolumeClaims(w http.ResponseWr
return httperror.InternalServerError("failed to retrieve persistent volume claims", err)
}
pvcs, err = cli.CombineClaimsWithApplications(pvcs)
if err != nil {
log.Warn().Err(err).Str("context", "GetAllKubernetesPersistentVolumeClaims").Msg("Failed to enrich PVCs with owning applications")
}
return response.JSON(w, pvcs)
}
@@ -83,6 +88,11 @@ func (handler *Handler) getKubernetesPVCsInNamespace(w http.ResponseWriter, r *h
return httperror.InternalServerError("failed to retrieve persistent volume claims", err)
}
pvcs, err = cli.CombineClaimsWithApplications(pvcs)
if err != nil {
log.Warn().Err(err).Str("context", "GetKubernetesPVCsInNamespace").Str("namespace", namespace).Msg("Failed to enrich PVCs with owning applications")
}
return response.JSON(w, pvcs)
}
@@ -7,7 +7,9 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -167,3 +169,87 @@ func parsePersistentVolumeClaimDetail(pvc *corev1.PersistentVolumeClaim) models.
Labels: pvc.Labels,
}
}
// CombineClaimsWithApplications enriches each PVC with the workloads that mount it.
func (kcl *KubeClient) CombineClaimsWithApplications(pvcs []models.K8sPersistentVolumeClaim) ([]models.K8sPersistentVolumeClaim, error) {
pods, err := kcl.cli.CoreV1().Pods("").List(context.Background(), metav1.ListOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
return pvcs, nil
}
return nil, fmt.Errorf("failed to list pods: %w", err)
}
replicaSetItems := make([]appsv1.ReplicaSet, 0)
deploymentItems := make([]appsv1.Deployment, 0)
if containsReplicaSetOwnerReference(pods) {
replicaSets, err := kcl.cli.AppsV1().ReplicaSets("").List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list replica sets: %w", err)
}
replicaSetItems = replicaSets.Items
deployments, err := kcl.cli.AppsV1().Deployments("").List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list deployments: %w", err)
}
deploymentItems = deployments.Items
}
statefulSetItems := make([]appsv1.StatefulSet, 0)
if containsStatefulSetOwnerReference(pods) {
statefulSets, err := kcl.cli.AppsV1().StatefulSets("").List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list stateful sets: %w", err)
}
statefulSetItems = statefulSets.Items
}
daemonSetItems := make([]appsv1.DaemonSet, 0)
if containsDaemonSetOwnerReference(pods) {
daemonSets, err := kcl.cli.AppsV1().DaemonSets("").List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list daemon sets: %w", err)
}
daemonSetItems = daemonSets.Items
}
resources := PortainerApplicationResources{
ReplicaSets: replicaSetItems,
Deployments: deploymentItems,
StatefulSets: statefulSetItems,
DaemonSets: daemonSetItems,
}
for i := range pvcs {
for _, pod := range pods.Items {
for _, podVolume := range pod.Spec.Volumes {
if podVolume.PersistentVolumeClaim == nil {
continue
}
if podVolume.PersistentVolumeClaim.ClaimName != pvcs[i].Name || pod.Namespace != pvcs[i].Namespace {
continue
}
application, err := kcl.ConvertPodToApplication(pod, resources, false)
if err != nil {
return nil, fmt.Errorf("failed to convert pod to application: %w", err)
}
if application == nil {
continue
}
alreadyAdded := false
for _, existing := range pvcs[i].OwningApplications {
if existing.Name == application.Name && existing.Namespace == application.Namespace {
alreadyAdded = true
break
}
}
if !alreadyAdded {
pvcs[i].OwningApplications = append(pvcs[i].OwningApplications, *application)
}
}
}
}
return pvcs, nil
}
@@ -5,6 +5,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
"k8s.io/apimachinery/pkg/api/resource"
@@ -154,3 +155,224 @@ func TestGetPersistentVolumeClaims_EnrichesExpansionFlag(t *testing.T) {
assert.False(t, pvcs[0].AllowVolumeExpansion)
})
}
func makePod(namespace, name, pvcName string) *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: corev1.PodSpec{
Volumes: []corev1.Volume{
{
Name: "data",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: pvcName,
},
},
},
},
Containers: []corev1.Container{{Name: "app", Image: "nginx"}},
},
}
}
func makePodWithDeploymentOwner(namespace, name, pvcName, rsName string) *corev1.Pod {
pod := makePod(namespace, name, pvcName)
pod.OwnerReferences = []metav1.OwnerReference{
{Kind: "ReplicaSet", Name: rsName, APIVersion: "apps/v1"},
}
return pod
}
func makeReplicaSet(namespace, name, deploymentName string) *appsv1.ReplicaSet {
return &appsv1.ReplicaSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
OwnerReferences: []metav1.OwnerReference{
{Kind: "Deployment", Name: deploymentName, APIVersion: "apps/v1"},
},
},
}
}
func makeDeployment(namespace, name string) *appsv1.Deployment {
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{},
},
}
}
func TestCombineClaimsWithApplications(t *testing.T) {
t.Parallel()
t.Run("no pods leaves owningApplications empty", func(t *testing.T) {
t.Parallel()
k := NewTestKubeClient(kfake.NewClientset())
pvc := makePVC("default", "my-pvc", "")
_, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{})
require.NoError(t, err)
pvcs, err := k.GetPersistentVolumeClaims("default")
require.NoError(t, err)
result, err := k.CombineClaimsWithApplications(pvcs)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Empty(t, result[0].OwningApplications)
})
t.Run("pod with no PVC volumes leaves owningApplications empty", func(t *testing.T) {
t.Parallel()
k := NewTestKubeClient(kfake.NewClientset())
pvc := makePVC("default", "my-pvc", "")
_, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{})
require.NoError(t, err)
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default"},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "app", Image: "nginx"}},
},
}
_, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{})
require.NoError(t, err)
pvcs, err := k.GetPersistentVolumeClaims("default")
require.NoError(t, err)
result, err := k.CombineClaimsWithApplications(pvcs)
require.NoError(t, err)
assert.Empty(t, result[0].OwningApplications)
})
t.Run("pod mounting PVC populates owningApplications", func(t *testing.T) {
t.Parallel()
k := NewTestKubeClient(kfake.NewClientset())
pvc := makePVC("default", "my-pvc", "")
_, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{})
require.NoError(t, err)
pod := makePod("default", "my-pod", "my-pvc")
_, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{})
require.NoError(t, err)
pvcs, err := k.GetPersistentVolumeClaims("default")
require.NoError(t, err)
result, err := k.CombineClaimsWithApplications(pvcs)
require.NoError(t, err)
require.Len(t, result[0].OwningApplications, 1)
assert.Equal(t, "my-pod", result[0].OwningApplications[0].Name)
assert.Equal(t, "Pod", result[0].OwningApplications[0].ApplicationType)
assert.Equal(t, "default", result[0].OwningApplications[0].ResourcePool)
})
t.Run("pod in different namespace does not match PVC", func(t *testing.T) {
t.Parallel()
k := NewTestKubeClient(kfake.NewClientset())
pvc := makePVC("default", "my-pvc", "")
_, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{})
require.NoError(t, err)
pod := makePod("other-ns", "other-pod", "my-pvc")
_, err = k.cli.CoreV1().Pods("other-ns").Create(t.Context(), pod, metav1.CreateOptions{})
require.NoError(t, err)
pvcs, err := k.GetPersistentVolumeClaims("default")
require.NoError(t, err)
result, err := k.CombineClaimsWithApplications(pvcs)
require.NoError(t, err)
assert.Empty(t, result[0].OwningApplications)
})
t.Run("two pods mounting the same PVC from the same Deployment are deduplicated", func(t *testing.T) {
t.Parallel()
k := NewTestKubeClient(kfake.NewClientset())
pvc := makePVC("default", "shared-pvc", "")
_, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{})
require.NoError(t, err)
deploy := makeDeployment("default", "my-deploy")
_, err = k.cli.AppsV1().Deployments("default").Create(t.Context(), deploy, metav1.CreateOptions{})
require.NoError(t, err)
rs := makeReplicaSet("default", "my-deploy-rs", "my-deploy")
_, err = k.cli.AppsV1().ReplicaSets("default").Create(t.Context(), rs, metav1.CreateOptions{})
require.NoError(t, err)
pod1 := makePodWithDeploymentOwner("default", "my-deploy-pod-1", "shared-pvc", "my-deploy-rs")
pod2 := makePodWithDeploymentOwner("default", "my-deploy-pod-2", "shared-pvc", "my-deploy-rs")
_, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod1, metav1.CreateOptions{})
require.NoError(t, err)
_, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod2, metav1.CreateOptions{})
require.NoError(t, err)
pvcs, err := k.GetPersistentVolumeClaims("default")
require.NoError(t, err)
result, err := k.CombineClaimsWithApplications(pvcs)
require.NoError(t, err)
require.Len(t, result[0].OwningApplications, 1)
assert.Equal(t, "my-deploy", result[0].OwningApplications[0].Name)
assert.Equal(t, "Deployment", result[0].OwningApplications[0].ApplicationType)
})
t.Run("pod owned by Deployment resolves application name to Deployment", func(t *testing.T) {
t.Parallel()
k := NewTestKubeClient(kfake.NewClientset())
pvc := makePVC("default", "app-pvc", "")
_, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{})
require.NoError(t, err)
deploy := makeDeployment("default", "web-app")
_, err = k.cli.AppsV1().Deployments("default").Create(t.Context(), deploy, metav1.CreateOptions{})
require.NoError(t, err)
rs := makeReplicaSet("default", "web-app-rs-abc", "web-app")
_, err = k.cli.AppsV1().ReplicaSets("default").Create(t.Context(), rs, metav1.CreateOptions{})
require.NoError(t, err)
pod := makePodWithDeploymentOwner("default", "web-app-pod-xyz", "app-pvc", "web-app-rs-abc")
_, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{})
require.NoError(t, err)
pvcs, err := k.GetPersistentVolumeClaims("default")
require.NoError(t, err)
result, err := k.CombineClaimsWithApplications(pvcs)
require.NoError(t, err)
require.Len(t, result[0].OwningApplications, 1)
assert.Equal(t, "web-app", result[0].OwningApplications[0].Name)
assert.Equal(t, "Deployment", result[0].OwningApplications[0].ApplicationType)
assert.Equal(t, "default", result[0].OwningApplications[0].ResourcePool)
})
t.Run("unrelated pod mounting a different PVC does not affect result", func(t *testing.T) {
t.Parallel()
k := NewTestKubeClient(kfake.NewClientset())
pvc := makePVC("default", "target-pvc", "")
_, err := k.cli.CoreV1().PersistentVolumeClaims("default").Create(t.Context(), pvc, metav1.CreateOptions{})
require.NoError(t, err)
pod := makePod("default", "unrelated-pod", "other-pvc")
_, err = k.cli.CoreV1().Pods("default").Create(t.Context(), pod, metav1.CreateOptions{})
require.NoError(t, err)
pvcs, err := k.GetPersistentVolumeClaims("default")
require.NoError(t, err)
result, err := k.CombineClaimsWithApplications(pvcs)
require.NoError(t, err)
assert.Empty(t, result[0].OwningApplications)
})
}
@@ -90,6 +90,28 @@ const mockPVCs: PersistentVolumeClaim[] = [
},
];
const pvcWithWorkload: PersistentVolumeClaim = {
id: 'pvc-3',
name: 'used-pvc',
namespace: 'default',
storage: 1073741824,
storageRequest: '1Gi',
creationDate: '2024-01-03T00:00:00Z',
accessModes: ['ReadWriteOnce'],
humanReadableAccessModes: ['ReadWriteOnce'],
volumeName: 'pv-3',
storageClass: 'standard',
allowVolumeExpansion: false,
phase: 'Bound',
owningApplications: [
{
Name: 'my-deployment',
ResourcePool: 'default',
ApplicationType: 'Deployment',
},
],
};
const mockNamespaces = [
{ Name: 'default', IsSystem: false },
{ Name: 'kube-system', IsSystem: true },
@@ -188,6 +210,90 @@ describe('PersistentVolumeClaimsDatatable', () => {
expect(screen.queryByTestId('resize-form')).not.toBeInTheDocument();
});
describe('Used by column', () => {
it('shows the owning application name for PVCs with a workload', async () => {
vi.mocked(usePersistentVolumeClaims).mockImplementation(
(_envId, options) => {
const data = [pvcWithWorkload];
return {
data: options?.select ? options.select(data) : data,
isLoading: false,
} as ReturnType<typeof usePersistentVolumeClaims>;
}
);
renderComponent();
expect(await screen.findByText('my-deployment')).toBeVisible();
});
it('shows a dash for PVCs with no owning applications', async () => {
vi.mocked(usePersistentVolumeClaims).mockImplementation(
(_envId, options) => {
const data = [mockPVCs[0]];
return {
data: options?.select ? options.select(data) : data,
isLoading: false,
} as ReturnType<typeof usePersistentVolumeClaims>;
}
);
renderComponent();
await screen.findByText('test-pvc-1');
expect(screen.getByText('-')).toBeVisible();
});
});
describe('row selectability', () => {
it('disables the row checkbox for PVCs with owning applications', async () => {
vi.mocked(usePersistentVolumeClaims).mockImplementation(
(_envId, options) => {
const data = [mockPVCs[0], pvcWithWorkload];
return {
data: options?.select ? options.select(data) : data,
isLoading: false,
} as ReturnType<typeof usePersistentVolumeClaims>;
}
);
const { container } = renderComponent();
await screen.findByText('used-pvc');
// skip header checkbox (index 0); row checkboxes follow in render order
const rowCheckboxes = Array.from(
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
).slice(1);
const [freeCheckbox, usedCheckbox] = rowCheckboxes;
expect(freeCheckbox).not.toBeDisabled();
expect(usedCheckbox).toBeDisabled();
});
it('allows selection of a Bound PVC that has no owning applications', async () => {
vi.mocked(usePersistentVolumeClaims).mockImplementation(
(_envId, options) => {
const data = [mockPVCs[0]]; // Bound, no owningApplications
return {
data: options?.select ? options.select(data) : data,
isLoading: false,
} as ReturnType<typeof usePersistentVolumeClaims>;
}
);
const { container } = renderComponent();
await screen.findByText('test-pvc-1');
const rowCheckboxes = Array.from(
container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
).slice(1);
expect(rowCheckboxes[0]).not.toBeDisabled();
});
});
it('hides system namespace PVCs by default', async () => {
vi.mocked(useNamespacesQuery).mockReturnValue({
data: mockNamespaces,
@@ -68,11 +68,13 @@ export function PersistentVolumeClaimsDatatable() {
title="Volume claims"
titleIcon={Database}
disableSelect={!hasWriteAuth}
isRowSelectable={({ original: claim }) => claim.phase !== 'Bound'}
isRowSelectable={({ original: claim }) =>
!claim.owningApplications?.length
}
renderTableActions={(selectedItems) => (
<Authorized authorizations="K8sVolumesW">
<DeleteButton
confirmMessage="Do you want to remove the selected volume(s)?"
confirmMessage="Do you want to remove the selected volume claim(s)?"
onConfirmed={() => deleteClaimsMutation.mutate(selectedItems)}
disabled={selectedItems.length === 0}
isLoading={deleteClaimsMutation.isLoading}
@@ -15,12 +15,12 @@ import { DeleteButton } from '@@/buttons/DeleteButton';
import { Modal } from '@@/modals';
import { systemResourcesSettings } from '../../datatables/SystemResourcesSettings';
import { isPersistentVolumeUsed } from '../utils';
import {
DefaultDatatableSettings,
TableSettings,
} from '../../datatables/DefaultDatatableSettings';
import { SystemResourceDescription } from '../../datatables/SystemResourceDescription';
import { isPersistentVolumeUsed } from '../utils';
import { createPersistentVolumesColumns } from './persistentVolumesColumns';
import { PersistentVolume } from './types';
@@ -40,6 +40,34 @@ export function createPersistentVolumeClaimsColumns(
helper.accessor('namespace', {
header: 'Namespace',
}),
helper.accessor('owningApplications', {
header: 'Used by',
id: 'owningApplications',
cell: ({ getValue }) => {
const apps = getValue();
if (!apps?.length) {
return '-';
}
return (
<div className="flex flex-col gap-y-1">
{apps.map((app) => (
<Link
key={app.Uid ?? app.Name}
to="kubernetes.applications.application"
params={{
name: app.Name,
namespace: app.ResourcePool,
'resource-type': app.ApplicationType,
}}
data-cy={`pvc-owning-app-${app.Name}`}
>
{app.Name}
</Link>
))}
</div>
);
},
}),
helper.accessor('phase', {
header: 'Status',
cell: ({ getValue }) => {
@@ -13,6 +13,7 @@ export function convertToVolumeViewModels(
return {
Applications: owningApplications.map((app) => ({
Name: app.Name,
ResourcePool: app.ResourcePool,
Namespace: app.Namespace,
Kind: app.Kind,
})),
+2
View File
@@ -50,6 +50,8 @@ interface K8sStorageClass {
export interface K8sVolOwningApplication {
Uid?: string;
Name: string;
ResourcePool: string;
ApplicationType?: string;
Namespace?: string;
Kind?: string;
Labels?: { [key: string]: string };