1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 05:19:39 +02:00

fix(app/kubernetes): Fix listing of secrets and configmaps with same name [r8s-288] (#897)

This commit is contained in:
James Player 2025-07-16 16:37:59 +12:00 committed by GitHub
parent 383bcc4113
commit 4e4c5ffdb6
4 changed files with 1316 additions and 25 deletions

View file

@ -0,0 +1,702 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { vi, beforeEach, afterEach } from 'vitest';
import { Application } from '../../types';
import { ApplicationEnvVarsTable } from './ApplicationEnvVarsTable';
// Mock icon components
vi.mock('lucide-react', () => ({
Asterisk: () => <span data-cy="asterisk-icon" />,
File: () => <span data-cy="file-icon" />,
FileCode: () => <span data-cy="file-code-icon" />,
Key: () => <span data-cy="key-icon" />,
Lock: () => <span data-cy="lock-icon" />,
}));
// Mock UI components
vi.mock('@@/Icon', () => ({
Icon: ({
icon: IconComponent,
...props
}: {
icon: React.ComponentType;
[key: string]: unknown;
}) => <IconComponent {...props} />,
}));
vi.mock('@@/Tip/TextTip', () => ({
TextTip: ({
children,
color,
}: {
children: React.ReactNode;
color?: string;
}) => (
<div data-cy="text-tip" data-color={color}>
{children}
</div>
),
}));
// Mock the Link component to capture routing props
const mockLink = vi.fn();
vi.mock('@@/Link', () => ({
Link: ({
children,
to,
params,
'data-cy': dataCy,
className,
}: {
children: React.ReactNode;
to: string;
params: Record<string, string>;
'data-cy'?: string;
className?: string;
}) => {
mockLink({ children, to, params, 'data-cy': dataCy, className });
return (
<span
data-cy={dataCy}
data-testid={dataCy}
role="link"
data-to={to}
data-params={JSON.stringify(params)}
className={className}
>
{children}
</span>
);
},
}));
describe('ApplicationEnvVarsTable', () => {
beforeEach(() => {
mockLink.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render helpful tip when there are no environment variables', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(
screen.getByText('Environment variables, ConfigMaps or Secrets')
).toBeInTheDocument();
expect(screen.getByTestId('text-tip')).toBeInTheDocument();
expect(
screen.getByText(
'This application is not using any environment variable, ConfigMap or Secret.'
)
).toBeInTheDocument();
});
it('should render nothing when app is undefined', () => {
render(<ApplicationEnvVarsTable namespace="default" app={undefined} />);
expect(
screen.getByText('Environment variables, ConfigMaps or Secrets')
).toBeInTheDocument();
expect(screen.getByTestId('text-tip')).toBeInTheDocument();
});
it('should render regular environment variables with direct values', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: 'ENV_VAR',
value: 'test-value',
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('ENV_VAR')).toBeInTheDocument();
expect(screen.getByText('test-value')).toBeInTheDocument();
expect(screen.getByText('-')).toBeInTheDocument(); // No configuration resource
});
it('should render configmap environment variables with correct routing', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: 'CONFIG_VAR',
valueFrom: {
configMapKeyRef: {
name: 'test-configmap',
key: 'config-key',
},
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns
// Note: config-key is not displayed in UI - the component shows the env var name
expect(screen.getByText('test-configmap')).toBeInTheDocument();
expect(screen.getByTestId('key-icon')).toBeInTheDocument();
expect(screen.getByTestId('file-code-icon')).toBeInTheDocument();
// Verify the Link component was called with correct routing parameters
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.configmaps.configmap',
params: {
name: 'test-configmap',
namespace: 'default',
},
'data-cy': 'configmap-link-test-configmap',
})
);
});
it('should render secret environment variables with correct routing', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: 'SECRET_VAR',
valueFrom: {
secretKeyRef: {
name: 'test-secret',
key: 'secret-key',
},
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getAllByText('SECRET_VAR')).toHaveLength(2); // Appears in name and value columns
// Note: secret-key is not displayed in UI - the component shows the env var name
expect(screen.getByText('test-secret')).toBeInTheDocument();
expect(screen.getByTestId('key-icon')).toBeInTheDocument();
expect(screen.getByTestId('lock-icon')).toBeInTheDocument();
// Verify the Link component was called with correct routing parameters for secret
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.secrets.secret',
params: {
name: 'test-secret',
namespace: 'default',
},
'data-cy': 'configmap-link-test-secret',
})
);
});
it('should render downward API field references', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: 'POD_NAME',
valueFrom: {
fieldRef: {
fieldPath: 'metadata.name',
},
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('POD_NAME')).toBeInTheDocument();
expect(
screen.getByText(
(content, element) =>
content.includes('metadata.name') && element?.tagName === 'SPAN'
)
).toBeInTheDocument();
expect(screen.getByText('downward API')).toBeInTheDocument();
expect(screen.getByTestId('asterisk-icon')).toBeInTheDocument();
expect(screen.getByText('-')).toBeInTheDocument(); // No configuration resource
});
it('should render envFrom configmap (entire configmap import)', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
envFrom: [
{
configMapRef: {
name: 'entire-configmap',
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getAllByText('-')).toHaveLength(2); // EnvFrom doesn't have a specific key name or value
expect(screen.getByText('entire-configmap')).toBeInTheDocument();
expect(screen.getByTestId('file-code-icon')).toBeInTheDocument();
// Verify configmap routing
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.configmaps.configmap',
params: {
name: 'entire-configmap',
namespace: 'default',
},
'data-cy': 'configmap-link-entire-configmap',
})
);
});
it('should render envFrom secret (entire secret import)', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
envFrom: [
{
secretRef: {
name: 'entire-secret',
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getAllByText('-')).toHaveLength(2); // EnvFrom doesn't have a specific key name or value
expect(screen.getByText('entire-secret')).toBeInTheDocument();
expect(screen.getByTestId('lock-icon')).toBeInTheDocument();
// Verify secret routing
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.secrets.secret',
params: {
name: 'entire-secret',
namespace: 'default',
},
'data-cy': 'configmap-link-entire-secret',
})
);
});
it('should render init containers with asterisk indicator', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'main-container',
image: 'main-image',
env: [
{
name: 'MAIN_VAR',
value: 'main-value',
},
],
},
],
initContainers: [
{
name: 'init-container',
image: 'init-image',
env: [
{
name: 'INIT_VAR',
value: 'init-value',
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
// Check main container
expect(screen.getByText('main-container')).toBeInTheDocument();
expect(screen.getByText('MAIN_VAR')).toBeInTheDocument();
expect(screen.getByText('main-value')).toBeInTheDocument();
// Check init container
expect(screen.getByText('init-container')).toBeInTheDocument();
expect(screen.getByText('INIT_VAR')).toBeInTheDocument();
expect(screen.getByText('init-value')).toBeInTheDocument();
expect(screen.getByText('init container')).toBeInTheDocument();
expect(screen.getByTestId('asterisk-icon')).toBeInTheDocument();
});
it('should handle mixed environment variable types correctly', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: 'REGULAR_VAR',
value: 'regular-value',
},
{
name: 'CONFIG_VAR',
valueFrom: {
configMapKeyRef: {
name: 'test-configmap',
key: 'config-key',
},
},
},
{
name: 'SECRET_VAR',
valueFrom: {
secretKeyRef: {
name: 'test-secret',
key: 'secret-key',
},
},
},
],
envFrom: [
{
configMapRef: {
name: 'entire-configmap',
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getAllByText('test-container')).toHaveLength(4); // Should appear 4 times - once for each env var
expect(screen.getByText('REGULAR_VAR')).toBeInTheDocument();
expect(screen.getByText('regular-value')).toBeInTheDocument();
expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns
// Note: config-key is not displayed in UI - the component shows the env var name
expect(screen.getAllByText('SECRET_VAR')).toHaveLength(2); // Appears in name and value columns
// Note: secret-key is not displayed in UI - the component shows the env var name
expect(screen.getByText('test-configmap')).toBeInTheDocument();
expect(screen.getByText('test-secret')).toBeInTheDocument();
expect(screen.getByText('entire-configmap')).toBeInTheDocument();
// Should have made multiple Link calls
expect(mockLink).toHaveBeenCalledTimes(3);
// Verify different routing calls
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.configmaps.configmap',
})
);
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.secrets.secret',
})
);
});
it('should handle Deployment kind applications', () => {
const app: Application = {
metadata: { name: 'test-deployment', namespace: 'default' },
spec: {
selector: {
matchLabels: {
app: 'test-app',
},
},
template: {
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: 'ENV_VAR',
value: 'test-value',
},
],
},
],
},
},
},
kind: 'Deployment',
apiVersion: 'apps/v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('ENV_VAR')).toBeInTheDocument();
expect(screen.getByText('test-value')).toBeInTheDocument();
});
it('should handle missing resource names gracefully', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: 'CONFIG_VAR',
valueFrom: {
configMapKeyRef: {
// name is undefined
key: 'config-key',
},
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns
// Note: config-key is not displayed in UI - the component shows the env var name
// Should show dash for missing resource name
const dashElements = screen.getAllByText('-');
expect(dashElements.length).toBeGreaterThan(0);
});
it('should handle containers without environment variables', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
// No env or envFrom
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(
screen.getByText('Environment variables, ConfigMaps or Secrets')
).toBeInTheDocument();
expect(screen.getByTestId('text-tip')).toBeInTheDocument();
expect(
screen.getByText(
'This application is not using any environment variable, ConfigMap or Secret.'
)
).toBeInTheDocument();
});
it('should handle environment variables without keys', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
env: [
{
name: '', // Empty name to test this edge case
value: 'test-value',
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('test-value')).toBeInTheDocument();
// Should show dash for missing env var name
const dashElements = screen.getAllByText('-');
expect(dashElements.length).toBeGreaterThan(0);
});
it('should render multiple containers with different environment variable types', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'container-1',
image: 'image-1',
env: [
{
name: 'CONFIG_VAR',
valueFrom: {
configMapKeyRef: {
name: 'shared-config',
key: 'config-key',
},
},
},
],
},
{
name: 'container-2',
image: 'image-2',
env: [
{
name: 'SECRET_VAR',
valueFrom: {
secretKeyRef: {
name: 'shared-config', // Same name but different type
key: 'secret-key',
},
},
},
],
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationEnvVarsTable namespace="default" app={app} />);
expect(screen.getByText('container-1')).toBeInTheDocument();
expect(screen.getByText('container-2')).toBeInTheDocument();
expect(screen.getAllByText('CONFIG_VAR')).toHaveLength(2); // Appears in name and value columns
expect(screen.getAllByText('SECRET_VAR')).toHaveLength(2); // Appears in name and value columns
expect(screen.getAllByText('shared-config')).toHaveLength(2);
// Should have made two Link calls - one for configmap, one for secret
expect(mockLink).toHaveBeenCalledTimes(2);
// Verify configmap routing
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.configmaps.configmap',
params: {
name: 'shared-config',
namespace: 'default',
},
})
);
// Verify secret routing
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.secrets.secret',
params: {
name: 'shared-config',
namespace: 'default',
},
})
);
});
});

View file

@ -84,8 +84,8 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
))}
</td>
<td data-cy="k8sAppDetail-configName">
{!envVar.resourseName && <span>-</span>}
{envVar.resourseName && (
{!envVar.resourceName && <span>-</span>}
{envVar.resourceName && (
<span>
<Link
to={
@ -94,17 +94,17 @@ export function ApplicationEnvVarsTable({ namespace, app }: Props) {
: 'kubernetes.secrets.secret'
}
params={{
name: envVar.resourseName,
name: envVar.resourceName,
namespace,
}}
className="flex items-center"
data-cy={`configmap-link-${envVar.resourseName}`}
data-cy={`configmap-link-${envVar.resourceName}`}
>
<Icon
icon={envVar.type === 'configMap' ? FileCode : Lock}
className="!mr-1"
/>
{envVar.resourseName}
{envVar.resourceName}
</Link>
</span>
)}
@ -126,7 +126,7 @@ interface ContainerEnvVar {
containerName: string;
isInitContainer: boolean;
type: EnvVarType;
resourseName: string;
resourceName: string;
}
function getApplicationEnvironmentVariables(
@ -159,7 +159,7 @@ function getApplicationEnvironmentVariables(
containerName: container.name,
isInitContainer: false,
type: envtype,
resourseName:
resourceName:
envVar?.valueFrom?.configMapKeyRef?.name ||
envVar?.valueFrom?.secretKeyRef?.name ||
'',
@ -170,7 +170,7 @@ function getApplicationEnvironmentVariables(
const containerEnvFroms: ContainerEnvVar[] =
container?.envFrom?.map((envFrom) => ({
name: '',
resourseName:
resourceName:
envFrom?.configMapRef?.name || envFrom?.secretRef?.name || '',
containerName: container.name,
isInitContainer: false,
@ -196,7 +196,7 @@ function getApplicationEnvironmentVariables(
containerName: container.name,
isInitContainer: true,
type: envtype,
resourseName:
resourceName:
envVar?.valueFrom?.configMapKeyRef?.name ||
envVar?.valueFrom?.secretKeyRef?.name ||
'',
@ -207,7 +207,7 @@ function getApplicationEnvironmentVariables(
const containerEnvFroms: ContainerEnvVar[] =
container?.envFrom?.map((envFrom) => ({
name: '',
resourseName:
resourceName:
envFrom?.configMapRef?.name || envFrom?.secretRef?.name || '',
containerName: container.name,
isInitContainer: true,

View file

@ -0,0 +1,583 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { vi, beforeEach, afterEach } from 'vitest';
import { Application } from '../../types';
import { ApplicationVolumeConfigsTable } from './ApplicationVolumeConfigsTable';
// Mock icon components
vi.mock('lucide-react', () => ({
Asterisk: () => <span data-cy="asterisk-icon" />,
Plus: () => <span data-cy="plus-icon" />,
}));
// Mock UI components
vi.mock('@@/Icon', () => ({
Icon: ({
icon: IconComponent,
...props
}: {
icon: React.ComponentType;
[key: string]: unknown;
}) => <IconComponent {...props} />,
}));
// Mock the Link component to capture routing props
const mockLink = vi.fn();
vi.mock('@@/Link', () => ({
Link: ({
children,
to,
params,
'data-cy': dataCy,
className,
}: {
children: React.ReactNode;
to: string;
params: Record<string, string>;
'data-cy'?: string;
className?: string;
}) => {
mockLink({ children, to, params, 'data-cy': dataCy, className });
return (
<span
data-cy={dataCy}
data-testid={dataCy}
role="link"
data-to={to}
data-params={JSON.stringify(params)}
className={className}
>
{children}
</span>
);
},
}));
describe('ApplicationVolumeConfigsTable', () => {
beforeEach(() => {
mockLink.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render nothing when there are no volume configurations', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
const { container } = render(
<ApplicationVolumeConfigsTable namespace="default" app={app} />
);
expect(container.firstChild).toBeNull();
});
it('should render nothing when app is undefined', () => {
const { container } = render(
<ApplicationVolumeConfigsTable namespace="default" app={undefined} />
);
expect(container.firstChild).toBeNull();
});
it('should render volume configurations from configmaps with items', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'config-volume',
mountPath: '/etc/config',
},
],
},
],
volumes: [
{
name: 'config-volume',
configMap: {
name: 'test-configmap',
items: [
{
key: 'config-key',
path: 'config.yaml',
},
],
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('/etc/config/config.yaml')).toBeInTheDocument();
expect(screen.getByText('config-key')).toBeInTheDocument();
expect(screen.getAllByTestId('plus-icon')).toHaveLength(2); // One for value, one for link
expect(screen.getByText('test-configmap')).toBeInTheDocument();
// Verify the Link component was called with correct routing parameters
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.configmaps.configmap',
params: {
name: 'test-configmap',
namespace: 'default',
},
'data-cy': 'config-link-test-configmap',
})
);
});
it('should render volume configurations from secrets with items', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'secret-volume',
mountPath: '/etc/secrets',
},
],
},
],
volumes: [
{
name: 'secret-volume',
secret: {
secretName: 'test-secret',
items: [
{
key: 'secret-key',
path: 'secret.txt',
},
],
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('/etc/secrets/secret.txt')).toBeInTheDocument();
expect(screen.getByText('secret-key')).toBeInTheDocument();
expect(screen.getAllByTestId('plus-icon')).toHaveLength(2); // One for value, one for link
expect(screen.getByText('test-secret')).toBeInTheDocument();
// Verify the Link component was called with correct routing parameters for secret
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.secrets.secret',
params: {
name: 'test-secret',
namespace: 'default',
},
'data-cy': 'secret-link-test-secret',
})
);
});
it('should render init containers with asterisk indicator', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'main-container',
image: 'main-image',
volumeMounts: [
{
name: 'config-volume',
mountPath: '/etc/config',
},
],
},
],
initContainers: [
{
name: 'init-container',
image: 'init-image',
volumeMounts: [
{
name: 'config-volume',
mountPath: '/etc/init-config',
},
],
},
],
volumes: [
{
name: 'config-volume',
configMap: {
name: 'shared-config',
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
// Check main container
expect(screen.getByText('main-container')).toBeInTheDocument();
expect(screen.getByText('/etc/config')).toBeInTheDocument();
// Check init container
expect(screen.getByText('init-container')).toBeInTheDocument();
expect(screen.getByText('/etc/init-config')).toBeInTheDocument();
expect(screen.getByTestId('asterisk-icon')).toBeInTheDocument();
expect(screen.getByText('init container')).toBeInTheDocument();
});
it('should render secret volume configurations correctly based on type', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'secret-volume',
mountPath: '/etc/secrets',
},
],
},
],
volumes: [
{
name: 'secret-volume',
secret: {
secretName: 'test-secret',
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('test-secret')).toBeInTheDocument();
// Should route to secret page because type is 'secret'
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.secrets.secret',
params: {
name: 'test-secret',
namespace: 'default',
},
'data-cy': 'secret-link-test-secret',
})
);
});
it('should render configmap volume configurations correctly based on type', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'config-volume',
mountPath: '/etc/config',
},
],
},
],
volumes: [
{
name: 'config-volume',
configMap: {
name: 'test-configmap',
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('test-configmap')).toBeInTheDocument();
// Should route to configmap page because type is 'configMap'
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.configmaps.configmap',
params: {
name: 'test-configmap',
namespace: 'default',
},
'data-cy': 'config-link-test-configmap',
})
);
});
it('should handle volumes without items (entire volume mount)', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'config-volume',
mountPath: '/etc/config',
},
],
},
],
volumes: [
{
name: 'config-volume',
configMap: {
name: 'test-configmap',
// No items - entire configmap is mounted
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('/etc/config')).toBeInTheDocument();
expect(screen.getByText('-')).toBeInTheDocument(); // No specific key
expect(screen.getByText('test-configmap')).toBeInTheDocument();
});
it('should handle multiple volumes with different types correctly', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'secret-volume',
mountPath: '/etc/secrets',
},
{
name: 'config-volume',
mountPath: '/etc/config',
},
],
},
],
volumes: [
{
name: 'secret-volume',
secret: {
secretName: 'test-secret',
},
},
{
name: 'config-volume',
configMap: {
name: 'test-configmap',
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getAllByText('test-container')).toHaveLength(2); // Should appear twice - once for each volume
expect(screen.getByText('test-secret')).toBeInTheDocument();
expect(screen.getByText('test-configmap')).toBeInTheDocument();
// Should have made two Link calls - one for secret, one for configmap
expect(mockLink).toHaveBeenCalledTimes(2);
// Verify secret link
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.secrets.secret',
params: {
name: 'test-secret',
namespace: 'default',
},
'data-cy': 'secret-link-test-secret',
})
);
// Verify configmap link
expect(mockLink).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kubernetes.configmaps.configmap',
params: {
name: 'test-configmap',
namespace: 'default',
},
'data-cy': 'config-link-test-configmap',
})
);
});
it('should handle containers without volume mounts', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
// No volumeMounts
},
],
volumes: [
{
name: 'config-volume',
configMap: {
name: 'test-configmap',
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
const { container } = render(
<ApplicationVolumeConfigsTable namespace="default" app={app} />
);
// Should render nothing because there are no matching volume mounts
expect(container.firstChild).toBeNull();
});
it('should handle Deployment kind applications', () => {
const app: Application = {
metadata: { name: 'test-deployment', namespace: 'default' },
spec: {
selector: {
matchLabels: {
app: 'test-app',
},
},
template: {
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'config-volume',
mountPath: '/etc/config',
},
],
},
],
volumes: [
{
name: 'config-volume',
configMap: {
name: 'test-configmap',
},
},
],
},
},
},
kind: 'Deployment',
apiVersion: 'apps/v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('/etc/config')).toBeInTheDocument();
expect(screen.getByText('test-configmap')).toBeInTheDocument();
});
it('should handle missing volume config names', () => {
const app: Application = {
metadata: { name: 'test-pod', namespace: 'default' },
spec: {
containers: [
{
name: 'test-container',
image: 'test-image',
volumeMounts: [
{
name: 'config-volume',
mountPath: '/etc/config',
},
],
},
],
volumes: [
{
name: 'config-volume',
configMap: {
// name is undefined
},
},
],
},
kind: 'Pod',
apiVersion: 'v1',
};
render(<ApplicationVolumeConfigsTable namespace="default" app={app} />);
expect(screen.getByText('test-container')).toBeInTheDocument();
expect(screen.getByText('/etc/config')).toBeInTheDocument();
// Should show dash for missing volume config name
const dashElements = screen.getAllByText('-');
expect(dashElements.length).toBeGreaterThan(0);
});
});

View file

@ -1,15 +1,23 @@
import { KeyToPath, Pod, Secret } from 'kubernetes-types/core/v1';
import { KeyToPath, Pod, VolumeMount } from 'kubernetes-types/core/v1';
import { Asterisk, Plus } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { Application } from '../../types';
import { applicationIsKind } from '../../utils';
type VolumeConfigType = 'configMap' | 'secret';
type AppVolumeConfig = {
volumeConfigName: string | undefined;
containerVolumeMount: VolumeMount | undefined;
containerName: string;
isInitContainer: boolean;
item: KeyToPath;
type: VolumeConfigType;
};
type Props = {
namespace: string;
app?: Application;
@ -18,8 +26,6 @@ type Props = {
export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
const containerVolumeConfigs = getApplicationVolumeConfigs(app);
const { data: secrets } = useK8sSecrets(useEnvironmentId(), namespace);
if (containerVolumeConfigs.length === 0) {
return null;
}
@ -41,6 +47,7 @@ export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
containerName,
item,
volumeConfigName,
type,
},
index
) => (
@ -76,7 +83,7 @@ export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
{!item.key && '-'}
</td>
<td>
{isVolumeConfigNameFromSecret(secrets, volumeConfigName) ? (
{type === 'secret' ? (
<Link
className="flex items-center"
to="kubernetes.secrets.secret"
@ -107,15 +114,8 @@ export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
);
}
function isVolumeConfigNameFromSecret(
secrets?: Secret[],
volumeConfigName?: string
) {
return secrets?.some((secret) => secret.metadata?.name === volumeConfigName);
}
// getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume
function getApplicationVolumeConfigs(app?: Application) {
function getApplicationVolumeConfigs(app?: Application): AppVolumeConfig[] {
if (!app) {
return [];
}
@ -142,6 +142,10 @@ function getApplicationVolumeConfigs(app?: Application) {
const containerVolumeMount = container.volumeMounts?.find(
(volumeMount) => volumeMount.name === volume.name
);
const type: VolumeConfigType = volume.configMap
? 'configMap'
: 'secret';
if (volConfigMapItems.length === 0) {
return [
{
@ -150,6 +154,7 @@ function getApplicationVolumeConfigs(app?: Application) {
containerName: container.name,
isInitContainer: appInitContainers.includes(container),
item: {} as KeyToPath,
type,
},
];
}
@ -160,6 +165,7 @@ function getApplicationVolumeConfigs(app?: Application) {
containerName: container.name,
isInitContainer: appInitContainers.includes(container),
item,
type,
}));
})
// only return the app volumes where the container volumeMounts include the volume name (from map step above)