Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"Project": "Project",
"Namespace": "Namespace",
"Enable Autoscale": "Enable Autoscale",
"This image is not intended to run with more than one replica. Scaling up may cause unexpected behavior.": "This image is not intended to run with more than one replica. Scaling up may cause unexpected behavior.",
"Increase the Pod count": "Increase the Pod count",
"Decrease the Pod count": "Decrease the Pod count",
"No Pods found for this resource.": "No Pods found for this resource.",
Expand Down
40 changes: 30 additions & 10 deletions frontend/packages/console-shared/src/components/pod/PodRing.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { FC } from 'react';
import { useState, useEffect } from 'react';
import { Button, Split, SplitItem, Bullseye } from '@patternfly/react-core';
import { Button, Split, SplitItem, Bullseye, Tooltip } from '@patternfly/react-core';
import { AngleDownIcon, AngleUpIcon, AutomationIcon } from '@patternfly/react-icons';
import * as _ from 'lodash';
import { useTranslation } from 'react-i18next';
import type { ImpersonateKind } from '@console/dynamic-plugin-sdk';
import type { K8sResourceKind, K8sKind } from '@console/internal/module/k8s';
import { k8sPatch } from '@console/internal/module/k8s';
import { useNonScalableImageCheck } from '../../hooks/useNonScalableImageCheck';
import { useRelatedHPA } from '../../hooks/useRelatedHPA';
import type { ExtPodKind } from '../../types';
import { usePodRingLabel, usePodScalingAccessStatus } from '../../utils/pod-ring-utils';
Expand Down Expand Up @@ -82,6 +83,7 @@ const PodRing: FC<PodRingProps> = ({
} = obj;
const [hpa] = useRelatedHPA(apiVersion, kind, name, namespace);
const hpaControlledScaling = !!hpa;
const { isNonScalable } = useNonScalableImageCheck(obj);

const isScalingAllowed = isAccessScalingAllowed && !hpaControlledScaling;

Expand Down Expand Up @@ -126,15 +128,33 @@ const PodRing: FC<PodRingProps> = ({
<SplitItem>
<Bullseye>
<div>
<Button
icon={<AngleUpIcon style={{ fontSize: '20' }} />}
type="button"
variant="plain"
aria-label={t('console-shared~Increase the Pod count')}
title={t('console-shared~Increase the Pod count')}
onClick={() => handleClick(1)}
isBlock
/>
{isNonScalable && clickCount >= 1 ? (
<Tooltip
content={t(
'console-shared~This image is not intended to run with more than one replica. Scaling up may cause unexpected behavior.',
)}
>
<Button
icon={<AngleUpIcon style={{ fontSize: '20' }} />}
type="button"
variant="plain"
aria-label={t('console-shared~Increase the Pod count')}
title={t('console-shared~Increase the Pod count')}
onClick={() => handleClick(1)}
isBlock
/>
</Tooltip>
) : (
<Button
icon={<AngleUpIcon style={{ fontSize: '20' }} />}
type="button"
variant="plain"
aria-label={t('console-shared~Increase the Pod count')}
title={t('console-shared~Increase the Pod count')}
onClick={() => handleClick(1)}
isBlock
/>
)}
<Button
icon={<AngleDownIcon style={{ fontSize: '20' }} />}
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { renderHook, waitFor } from '@testing-library/react';
import { k8sGet } from '@console/internal/module/k8s';
import { useNonScalableImageCheck } from '../useNonScalableImageCheck';

jest.mock('@console/internal/module/k8s', () => ({
...jest.requireActual('@console/internal/module/k8s'),
k8sGet: jest.fn(),
}));

const mockK8sGet = k8sGet as jest.Mock;

const makeDeploymentConfig = (istName?: string, namespace = 'test-ns') => ({
kind: 'DeploymentConfig',
apiVersion: 'apps.openshift.io/v1',
metadata: { name: 'test-dc', namespace },
spec: {
replicas: 1,
triggers: istName
? [
{
type: 'ImageChange',
imageChangeParams: {
from: { kind: 'ImageStreamTag', name: istName, namespace },
},
},
]
: [],
},
});

const makeDeployment = (istName?: string, namespace = 'test-ns') => ({
kind: 'Deployment',
apiVersion: 'apps/v1',
metadata: {
name: 'test-deploy',
namespace,
annotations: istName
? {
'image.openshift.io/triggers': JSON.stringify([
{ from: { kind: 'ImageStreamTag', name: istName, namespace } },
]),
}
: {},
},
spec: { replicas: 1 },
});

const makeIST = (nonScalable?: string | boolean) => {
const labels: Record<string, string | boolean> = {};
if (nonScalable !== undefined) {
labels['io.openshift.non-scalable'] = nonScalable;
}
return {
image: {
dockerImageMetadata: {
Config: {
Labels: labels,
},
},
},
};
};

describe('useNonScalableImageCheck', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return isNonScalable=true when IST has io.openshift.non-scalable=true (string)', async () => {
mockK8sGet.mockResolvedValue(makeIST('true'));
const resource = makeDeploymentConfig('myapp:latest');

const { result } = renderHook(() => useNonScalableImageCheck(resource));

await waitFor(() => {
expect(result.current.isNonScalable).toBe(true);
expect(result.current.loading).toBe(false);
});
});

it('should return isNonScalable=true when IST has io.openshift.non-scalable=true (boolean)', async () => {
mockK8sGet.mockResolvedValue(makeIST(true));
const resource = makeDeploymentConfig('myapp:latest');

const { result } = renderHook(() => useNonScalableImageCheck(resource));

await waitFor(() => {
expect(result.current.isNonScalable).toBe(true);
});
});

it('should return isNonScalable=false when IST does not have the label', async () => {
mockK8sGet.mockResolvedValue(makeIST());
const resource = makeDeploymentConfig('myapp:latest');

const { result } = renderHook(() => useNonScalableImageCheck(resource));

await waitFor(() => {
expect(result.current.isNonScalable).toBe(false);
expect(result.current.loading).toBe(false);
});
});

it('should return isNonScalable=false when there are no triggers', () => {
const resource = makeDeploymentConfig();

const { result } = renderHook(() => useNonScalableImageCheck(resource));

expect(result.current.isNonScalable).toBe(false);
expect(result.current.loading).toBe(false);
expect(mockK8sGet).not.toHaveBeenCalled();
});

it('should handle Deployment with image.openshift.io/triggers annotation', async () => {
mockK8sGet.mockResolvedValue(makeIST('true'));
const resource = makeDeployment('myapp:latest');

const { result } = renderHook(() => useNonScalableImageCheck(resource));

await waitFor(() => {
expect(result.current.isNonScalable).toBe(true);
});
});

it('should return isNonScalable=false when k8sGet fails', async () => {
mockK8sGet.mockRejectedValue(new Error('Forbidden'));
const resource = makeDeploymentConfig('myapp:latest');

const { result } = renderHook(() => useNonScalableImageCheck(resource));

await waitFor(() => {
expect(result.current.isNonScalable).toBe(false);
expect(result.current.loading).toBe(false);
});
});

it('should return isNonScalable=false for Deployment without trigger annotation', () => {
const resource = makeDeployment();

const { result } = renderHook(() => useNonScalableImageCheck(resource));

expect(result.current.isNonScalable).toBe(false);
expect(result.current.loading).toBe(false);
expect(mockK8sGet).not.toHaveBeenCalled();
});
});
114 changes: 114 additions & 0 deletions frontend/packages/console-shared/src/hooks/useNonScalableImageCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useState, useEffect, useMemo } from 'react';
import * as _ from 'lodash';
import { ImageStreamTagModel } from '@console/internal/models';
import type { K8sResourceKind } from '@console/internal/module/k8s';
import { k8sGet } from '@console/internal/module/k8s';

const NON_SCALABLE_LABEL = 'io.openshift.non-scalable';
const IMAGE_TRIGGER_ANNOTATION = 'image.openshift.io/triggers';

type ISTReference = {
name: string;
namespace: string;
};

const getISTFromDeploymentConfig = (resource: K8sResourceKind): ISTReference | null => {
const triggers = resource?.spec?.triggers;
if (!Array.isArray(triggers)) {
return null;
}
const imageChangeTrigger = triggers.find(
(trigger) =>
trigger.type === 'ImageChange' &&
trigger?.imageChangeParams?.from?.kind === 'ImageStreamTag' &&
!!trigger?.imageChangeParams?.from?.name,
);
if (!imageChangeTrigger) {
return null;
}
const { name, namespace } = imageChangeTrigger.imageChangeParams.from;
return {
name,
namespace: namespace || resource.metadata?.namespace,
};
};

const getISTFromTriggerAnnotation = (resource: K8sResourceKind): ISTReference | null => {
const annotation = resource?.metadata?.annotations?.[IMAGE_TRIGGER_ANNOTATION];
if (!annotation) {
return null;
}
try {
const triggers = JSON.parse(annotation);
const trigger = Array.isArray(triggers)
? triggers.find((t) => t?.from?.kind === 'ImageStreamTag' && !!t?.from?.name)
: null;
if (!trigger) {
return null;
}
return {
name: trigger.from.name,
namespace: trigger.from.namespace || resource.metadata?.namespace,
};
} catch {
return null;
}
};

const getISTReference = (resource: K8sResourceKind): ISTReference | null => {
if (resource?.kind === 'DeploymentConfig') {
return getISTFromDeploymentConfig(resource);
}
return getISTFromTriggerAnnotation(resource);
};

/**
* Checks if a workload's container image has the `io.openshift.non-scalable` label.
* Resolves the ImageStreamTag reference from the workload's triggers or annotations,
* fetches the IST, and inspects `image.dockerImageMetadata.Config.Labels`.
*
* Returns `{ isNonScalable: false }` silently on any error (missing IST, permissions, etc.).
*/
export const useNonScalableImageCheck = (
resource: K8sResourceKind,
): { isNonScalable: boolean; loading: boolean } => {
const [isNonScalable, setIsNonScalable] = useState(false);
const [loading, setLoading] = useState(true);

const istRef = useMemo(() => getISTReference(resource), [resource]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const istName = istRef?.name;
const istNamespace = istRef?.namespace;

useEffect(() => {
if (!istName || !istNamespace) {
setIsNonScalable(false);
setLoading(false);
return undefined;
}

let cancelled = false;
setLoading(true);

k8sGet(ImageStreamTagModel, istName, istNamespace)
.then((ist: K8sResourceKind) => {
if (!cancelled) {
const labels = _.get(ist, 'image.dockerImageMetadata.Config.Labels', {});
const nonScalableValue = labels[NON_SCALABLE_LABEL];
setIsNonScalable(nonScalableValue === true || nonScalableValue === 'true');
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setIsNonScalable(false);
setLoading(false);
}
});

return () => {
cancelled = true;
};
}, [istName, istNamespace]);

return { isNonScalable, loading };
};
Loading