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 @@ -232,6 +232,7 @@
"Namespace": "Namespace",
"Enable Autoscale": "Enable Autoscale",
"Increase the Pod count": "Increase the Pod count",
"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.",
"Decrease the Pod count": "Decrease the Pod count",
"No Pods found for this resource.": "No Pods found for this resource.",
"Hide waiting pods with errors": "Hide waiting pods with errors",
Expand Down
39 changes: 29 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/pod';
import { usePodRingLabel, usePodScalingAccessStatus } from '../../utils/pod-ring-utils';
Expand Down Expand Up @@ -82,6 +83,7 @@ export const PodRing: FC<PodRingProps> = ({
} = obj;
const [hpa] = useRelatedHPA(apiVersion, kind, name, namespace);
const hpaControlledScaling = !!hpa;
const { isNonScalable } = useNonScalableImageCheck(obj);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loading state is ignored — user can scale before the check completes

The hook returns { isNonScalable, loading } but both consumers (PodRing and ConfigureCountModal) destructure only isNonScalable. During the initial fetch, isNonScalable defaults to false, so the warning won't show until the IST fetch resolves. If the check matters enough to exist, consider disabling the scale-up button or showing a spinner while loading is true, to avoid a brief window where the user can scale without seeing the warning.


const isScalingAllowed = isAccessScalingAllowed && !hpaControlledScaling;

Expand Down Expand Up @@ -126,15 +128,32 @@ export 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
/>
{(() => {
const scaleUpButton = (
<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
/>
);
// Show tooltip preemptively at >= 1 replica so users see
// the non-scalable warning before attempting to scale up
return 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.',
)}
>
{scaleUpButton}
</Tooltip>
) : (
scaleUpButton
);
})()}
<Button
icon={<AngleDownIcon style={{ fontSize: '20' }} />}
type="button"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { renderHook } from '@testing-library/react';
import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook';
import { useNonScalableImageCheck } from '../useNonScalableImageCheck';

jest.mock('@console/internal/components/utils/k8s-watch-hook', () => ({
useK8sWatchResource: jest.fn(),
}));

const mockUseK8sWatchResource = useK8sWatchResource 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) => ({
image: {
dockerImageMetadata: {
Config: {
Labels: nonScalable !== undefined ? { 'io.openshift.non-scalable': nonScalable } : {},
},
},
},
});

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

it('should return isNonScalable=true when IST has io.openshift.non-scalable=true', () => {
mockUseK8sWatchResource.mockReturnValue([makeIST('true'), true, null]);
const resource = makeDeploymentConfig('myapp:latest');

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

expect(result.current.isNonScalable).toBe(true);
expect(result.current.loading).toBe(false);
});

it('should return isNonScalable=false when IST does not have the label', () => {
mockUseK8sWatchResource.mockReturnValue([makeIST(), true, null]);
const resource = makeDeploymentConfig('myapp:latest');

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

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

it('should pass null to useK8sWatchResource when there are no triggers', () => {
mockUseK8sWatchResource.mockReturnValue([null, true, null]);
const resource = makeDeploymentConfig();

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

expect(result.current.isNonScalable).toBe(false);
expect(mockUseK8sWatchResource).toHaveBeenCalledWith(null);
});

it('should handle Deployment with image.openshift.io/triggers annotation', () => {
mockUseK8sWatchResource.mockReturnValue([makeIST('true'), true, null]);
const resource = makeDeployment('myapp:latest');

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

expect(result.current.isNonScalable).toBe(true);
expect(mockUseK8sWatchResource).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'ImageStreamTag',
name: 'myapp:latest',
namespace: 'test-ns',
}),
);
});

it('should return isNonScalable=false when useK8sWatchResource returns an error', () => {
mockUseK8sWatchResource.mockReturnValue([null, true, new Error('Forbidden')]);
const resource = makeDeploymentConfig('myapp:latest');

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

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

it('should pass null to useK8sWatchResource for Deployment without trigger annotation', () => {
mockUseK8sWatchResource.mockReturnValue([null, true, null]);
const resource = makeDeployment();

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

expect(result.current.isNonScalable).toBe(false);
expect(mockUseK8sWatchResource).toHaveBeenCalledWith(null);
});

it('should return loading=true while useK8sWatchResource has not loaded', () => {
mockUseK8sWatchResource.mockReturnValue([null, false, null]);
const resource = makeDeploymentConfig('myapp:latest');

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

expect(result.current.isNonScalable).toBe(false);
expect(result.current.loading).toBe(true);
});

it('should pass null to useK8sWatchResource when resource is null', () => {
mockUseK8sWatchResource.mockReturnValue([null, true, null]);

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

expect(result.current.isNonScalable).toBe(false);
expect(mockUseK8sWatchResource).toHaveBeenCalledWith(null);
});

it('should return isNonScalable=false when label value is not "true"', () => {
mockUseK8sWatchResource.mockReturnValue([makeIST('false'), true, null]);
const resource = makeDeploymentConfig('myapp:latest');

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

expect(result.current.isNonScalable).toBe(false);
});
});
119 changes: 119 additions & 0 deletions frontend/packages/console-shared/src/hooks/useNonScalableImageCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useMemo } from 'react';
import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook';
import { ImageStreamTagModel } from '@console/internal/models';
import type { K8sResourceKind } 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;
};

type ImageStreamTagResource = K8sResourceKind & {
image?: {
dockerImageMetadata?: {
Config?: {
Labels?: Record<string, 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,
* watches the IST via `useK8sWatchResource`, and inspects
* `image.dockerImageMetadata.Config.Labels`.
*
* Pass `null` to skip the watch (e.g. for non-replica paths).
* Returns `{ isNonScalable: false }` silently on any error (missing IST, permissions, etc.).
*/
export const useNonScalableImageCheck = (
resource: K8sResourceKind | null,
): { isNonScalable: boolean; loading: boolean } => {
const triggerAnnotation = resource?.metadata?.annotations?.[IMAGE_TRIGGER_ANNOTATION];
const resourceKind = resource?.kind;
const resourceNamespace = resource?.metadata?.namespace;
const dcISTName = resource?.spec?.triggers?.find(
(t) => t?.type === 'ImageChange' && t?.imageChangeParams?.from?.kind === 'ImageStreamTag',
)?.imageChangeParams?.from?.name;

const istRef = useMemo(
() => (resource ? getISTReference(resource) : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[resourceKind, resourceNamespace, triggerAnnotation, dcISTName],
);

const istName = istRef?.name;
const istNamespace = istRef?.namespace;

const [ist, loaded, error] = useK8sWatchResource<ImageStreamTagResource>(
istName && istNamespace
? {
kind: ImageStreamTagModel.kind,
name: istName,
namespace: istNamespace,
isList: false,
}
: null,
);

const isNonScalable =
loaded && !error
? ist?.image?.dockerImageMetadata?.Config?.Labels?.[NON_SCALABLE_LABEL] === 'true'
: false;

return { isNonScalable, loading: !loaded };
};
Loading