diff --git a/locales/en/common.json b/locales/en/common.json index c06919fe..1ac5d8d2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -46,8 +46,13 @@ "DEPLOYMENT_ACTION_CONTAINER_NO_CONFIG": "None", "DEPLOYMENT_ACTION_CONTAINER_SINGLE_AUTO_SELECTED": "Single container automatically selected", "DEPLOYMENT_ACTION_CONTAINER_MUST_SELECT_ONE": "You must select exactly one container to instrument", + "DEPLOYMENT_ACTION_WIZARD_STEP_AGENT_CONFIG": "Agent Configuration", + "DEPLOYMENT_ACTION_WIZARD_STEP_AGENT_CONFIG_DESC": "Configure Agent settings", "DEPLOYMENT_ACTION_JAVA_OPTS_VAR_LABEL": "Java Options Environment Variable:", "DEPLOYMENT_ACTION_JAVA_OPTS_VAR_HELPER": "The environment variable name used to pass Java options to the JVM (default: JAVA_TOOL_OPTIONS)", + "DEPLOYMENT_ACTION_CALLBACK_PORT_LABEL": "Callback Port:", + "DEPLOYMENT_ACTION_CALLBACK_PORT_HELPER": "HTTP port for the Cryostat Agent (leave empty for Operator default)", + "DEPLOYMENT_ACTION_CALLBACK_PORT_PLACEHOLDER": "", "DEPLOYMENT_ACTION_HARVESTER_TEMPLATE_LABEL": "Harvester Template:", "DEPLOYMENT_ACTION_HARVESTER_TEMPLATE_NONE": "None", "DEPLOYMENT_ACTION_HARVESTER_TEMPLATE_CONTINUOUS": "Continuous", @@ -55,6 +60,10 @@ "DEPLOYMENT_ACTION_HARVESTER_TEMPLATE_NONE_DESC": "No automatic JFR harvesting", "DEPLOYMENT_ACTION_HARVESTER_TEMPLATE_CONTINUOUS_DESC": "Continuous JFR recording with periodic harvesting", "DEPLOYMENT_ACTION_HARVESTER_TEMPLATE_PROFILING_DESC": "Profiling template for performance analysis", + "DEPLOYMENT_ACTION_HARVESTER_PERIOD_LABEL": "Harvester Period:", + "DEPLOYMENT_ACTION_HARVESTER_PERIOD_HELPER": "How often to push JFR data to Cryostat (default: 15 minutes)", + "DEPLOYMENT_ACTION_HARVESTER_MAX_FILES_LABEL": "Harvester Max Files:", + "DEPLOYMENT_ACTION_HARVESTER_MAX_FILES_HELPER": "Maximum number of JFR files to retain locally (default: 4 files)", "DEPLOYMENT_ACTION_HARVESTER_EXIT_MAX_AGE_LABEL": "Harvester Exit Max Age:", "DEPLOYMENT_ACTION_HARVESTER_EXIT_MAX_AGE_HELPER": "Maximum age of data retained in the local recording file before it is pushed to Cryostat on shutdown (default: 5 minutes)", "DEPLOYMENT_ACTION_HARVESTER_EXIT_MAX_SIZE_LABEL": "Harvester Exit Max Size:", @@ -69,7 +78,10 @@ "DEPLOYMENT_ACTION_REVIEW_INSTANCE": "Cryostat Instance", "DEPLOYMENT_ACTION_REVIEW_CONTAINER": "Selected Container", "DEPLOYMENT_ACTION_REVIEW_JAVA_OPTS_VAR": "Java Options Variable", + "DEPLOYMENT_ACTION_REVIEW_CALLBACK_PORT": "Callback Port", "DEPLOYMENT_ACTION_REVIEW_HARVESTER": "Harvester Template", + "DEPLOYMENT_ACTION_REVIEW_HARVESTER_PERIOD": "Harvester Period", + "DEPLOYMENT_ACTION_REVIEW_HARVESTER_MAX_FILES": "Harvester Max Files", "DEPLOYMENT_ACTION_REVIEW_HARVESTER_EXIT_MAX_AGE": "Harvester Exit Max Age", "DEPLOYMENT_ACTION_REVIEW_HARVESTER_EXIT_MAX_SIZE": "Harvester Exit Max Size", "DEPLOYMENT_ACTION_REVIEW_LOG_LEVEL": "Log Level", diff --git a/src/openshift/actions/DeploymentLabelAction/AgentConfigStep.tsx b/src/openshift/actions/DeploymentLabelAction/AgentConfigStep.tsx new file mode 100644 index 00000000..a5c595de --- /dev/null +++ b/src/openshift/actions/DeploymentLabelAction/AgentConfigStep.tsx @@ -0,0 +1,104 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useCryostatTranslation } from '@i18n/i18nextUtil'; +import { + Form, + FormGroup, + TextInput, + NumberInput, + FormHelperText, + HelperText, + HelperTextItem, +} from '@patternfly/react-core'; +import * as React from 'react'; + +interface AgentConfigStepProps { + javaOptsVar: string; + callbackPort?: number; + onJavaOptsVarChange: (value: string) => void; + onCallbackPortChange: (value: number | undefined) => void; +} + +export const AgentConfigStep: React.FC = ({ + javaOptsVar, + callbackPort, + onJavaOptsVarChange, + onCallbackPortChange, +}) => { + const { t } = useCryostatTranslation(); + + const handleCallbackPortChange = (event: React.FormEvent) => { + const value = (event.target as HTMLInputElement).value; + if (value === '') { + onCallbackPortChange(undefined); + } else { + const numValue = Number(value); + if (!isNaN(numValue) && numValue > 0) { + onCallbackPortChange(numValue); + } + } + }; + + const handleCallbackPortMinus = () => { + if (callbackPort !== undefined && callbackPort > 1) { + onCallbackPortChange(callbackPort - 1); + } + }; + + const handleCallbackPortPlus = () => { + if (callbackPort !== undefined) { + onCallbackPortChange(callbackPort + 1); + } else { + onCallbackPortChange(1); + } + }; + + return ( +
+ + onJavaOptsVarChange(value)} + placeholder="JAVA_TOOL_OPTIONS" + /> + + + {t('DEPLOYMENT_ACTION_JAVA_OPTS_VAR_HELPER')} + + + + + + + + {t('DEPLOYMENT_ACTION_CALLBACK_PORT_HELPER')} + + + +
+ ); +}; diff --git a/src/openshift/actions/DeploymentLabelAction/ContainerSelectionStep.tsx b/src/openshift/actions/DeploymentLabelAction/ContainerSelectionStep.tsx index cc3c2b82..05854574 100644 --- a/src/openshift/actions/DeploymentLabelAction/ContainerSelectionStep.tsx +++ b/src/openshift/actions/DeploymentLabelAction/ContainerSelectionStep.tsx @@ -26,7 +26,7 @@ import { DescriptionListDescription, } from '@patternfly/react-core'; import * as React from 'react'; -import { Container, getAgentConfig, formatAgentConfig, LogLevel } from './envVarUtils'; +import { Container, getAgentConfig, formatAgentConfig, LogLevel } from './utils'; interface ContainerSelectionStepProps { containers: Container[]; diff --git a/src/openshift/actions/DeploymentLabelAction/DeploymentDecorator.tsx b/src/openshift/actions/DeploymentLabelAction/DeploymentDecorator.tsx index bdd5a97a..db34664a 100644 --- a/src/openshift/actions/DeploymentLabelAction/DeploymentDecorator.tsx +++ b/src/openshift/actions/DeploymentLabelAction/DeploymentDecorator.tsx @@ -17,6 +17,7 @@ import CryostatIcon from '@console-plugin/assets/CryostatIcon'; import { k8sGet, K8sResourceKind, useK8sModel, useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; import { Node } from '@patternfly/react-topology'; import * as React from 'react'; +import { AGENT_LABEL_KEYS } from './utils'; type DeploymentDecoratorProps = { element: Node; @@ -50,7 +51,7 @@ export const DeploymentDecorator: React.FC = ({ elemen React.useEffect(() => { if (deploymentLoaded && cryostatsLoaded) { const deploymentLabels = deployment.spec?.template.metadata.labels; - if (deploymentLabels && deploymentLabels['cryostat.io/name'] && deploymentLabels['cryostat.io/namespace']) { + if (deploymentLabels && deploymentLabels[AGENT_LABEL_KEYS.NAME] && deploymentLabels[AGENT_LABEL_KEYS.NAMESPACE]) { setIsRegistered(true); } else { setIsRegistered(false); @@ -61,16 +62,16 @@ export const DeploymentDecorator: React.FC = ({ elemen React.useEffect(() => { if (deploymentLoaded && cryostatsLoaded && isRegistered) { const labels = deployment.spec?.template.metadata.labels; - if (labels && labels['cryostat.io/name'] && labels['cryostat.io/namespace']) { + if (labels && labels[AGENT_LABEL_KEYS.NAME] && labels[AGENT_LABEL_KEYS.NAMESPACE]) { cryostats.forEach((cryostat) => { if ( - cryostat.metadata?.name === labels['cryostat.io/name'] && - cryostat.metadata?.namespace === labels['cryostat.io/namespace'] + cryostat.metadata?.name === labels[AGENT_LABEL_KEYS.NAME] && + cryostat.metadata?.namespace === labels[AGENT_LABEL_KEYS.NAMESPACE] ) { k8sGet({ model: routeModel, - name: labels['cryostat.io/name'], - ns: labels['cryostat.io/namespace'], + name: labels[AGENT_LABEL_KEYS.NAME], + ns: labels[AGENT_LABEL_KEYS.NAMESPACE], }) .catch(() => '') .then( diff --git a/src/openshift/actions/DeploymentLabelAction/DeploymentLabelActionModal.tsx b/src/openshift/actions/DeploymentLabelAction/DeploymentLabelActionModal.tsx index de9f959c..e6876051 100644 --- a/src/openshift/actions/DeploymentLabelAction/DeploymentLabelActionModal.tsx +++ b/src/openshift/actions/DeploymentLabelAction/DeploymentLabelActionModal.tsx @@ -37,22 +37,23 @@ import { } from '@patternfly/react-core'; import * as React from 'react'; +import { AgentConfigStep } from './AgentConfigStep'; import { ContainerSelectionStep } from './ContainerSelectionStep'; +import { HarvesterConfigStep } from './HarvesterConfigStep'; +import { InstanceSelectionStep } from './InstanceSelectionStep'; +import { LogLevelConfigStep } from './LogLevelConfigStep'; +import { ReviewStep } from './ReviewStep'; import { Container, HARVESTER_TEMPLATES, LOG_LEVELS, HarvesterTemplate, LogLevel, - AGENT_ENV_VARS, - getAgentConfig, - getEnvVarIndex, -} from './envVarUtils'; -import { HarvesterConfigStep } from './HarvesterConfigStep'; -import { InstanceSelectionStep } from './InstanceSelectionStep'; -import { JavaOptsConfigStep } from './JavaOptsConfigStep'; -import { LogLevelConfigStep } from './LogLevelConfigStep'; -import { ReviewStep } from './ReviewStep'; + parseDuration, + formatDurationForLabel, + formatByteSizeForLabel, + AGENT_LABEL_KEYS, +} from './utils'; interface CryostatModalProps { kind: K8sModel; @@ -66,7 +67,10 @@ interface WizardFormData { selectedContainerIndex: number; selectedContainerName: string; javaOptsVar: string; + callbackPort?: number; harvesterTemplate: HarvesterTemplate; + harvesterPeriodMs: number; + harvesterMaxFiles: number; harvesterExitMaxAgeMs: number; harvesterExitMaxSizeB: number; logLevel: LogLevel; @@ -79,7 +83,10 @@ const formDefaults: WizardFormData = { selectedContainerIndex: 0, selectedContainerName: '', javaOptsVar: 'JAVA_TOOL_OPTIONS', + callbackPort: undefined, harvesterTemplate: HARVESTER_TEMPLATES.CONTINUOUS, + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, harvesterExitMaxAgeMs: 300000, harvesterExitMaxSizeB: 20971520, logLevel: LOG_LEVELS.OFF, @@ -130,10 +137,11 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, }); const containers: Container[] = React.useMemo(() => { + const deploymentLabels = resource.spec?.template?.metadata?.labels || {}; return (resource.spec?.template?.spec?.containers || []).map((container: any) => ({ name: container.name, image: container.image, - env: container.env || [], + labels: deploymentLabels, })); }, [resource]); @@ -142,8 +150,8 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, return; } const deploymentLabels = resource.spec?.template.metadata.labels; - const name = deploymentLabels['cryostat.io/name']; - const namespace = deploymentLabels['cryostat.io/namespace']; + const name = deploymentLabels[AGENT_LABEL_KEYS.NAME]; + const namespace = deploymentLabels[AGENT_LABEL_KEYS.NAMESPACE]; for (let i = 0; i < cryostats.length; i++) { if (cryostats[i].metadata?.name === name && cryostats[i].metadata?.namespace === namespace) { setFormSelectValue(i.toString()); @@ -157,10 +165,17 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, React.useEffect(() => { if (containers.length > 0) { const firstContainer = containers[0]; - const agentConfig = getAgentConfig(firstContainer); const deploymentLabels = resource.spec?.template.metadata.labels; - const logLevelFromLabel = (deploymentLabels?.['cryostat.io/log-level'] as LogLevel) || LOG_LEVELS.OFF; - const javaOptsVarFromLabel = deploymentLabels?.['cryostat.io/java-options-var'] || 'JAVA_TOOL_OPTIONS'; + const logLevelFromLabel = (deploymentLabels?.[AGENT_LABEL_KEYS.LOG_LEVEL] as LogLevel) || LOG_LEVELS.OFF; + const javaOptsVarFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.JAVA_OPTIONS_VAR] || 'JAVA_TOOL_OPTIONS'; + const callbackPortFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.CALLBACK_PORT]; + const harvesterTemplateFromLabel = + (deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_TEMPLATE] as HarvesterTemplate) || + HARVESTER_TEMPLATES.CONTINUOUS; + const harvesterPeriodFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_PERIOD]; + const harvesterMaxFilesFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_MAX_FILES]; + const harvesterExitMaxAgeFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_AGE]; + const harvesterExitMaxSizeFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_SIZE]; setFormData((prev) => { const newData = { @@ -168,9 +183,12 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, selectedContainerIndex: 0, selectedContainerName: firstContainer.name, javaOptsVar: javaOptsVarFromLabel, - harvesterTemplate: agentConfig?.harvesterTemplate || HARVESTER_TEMPLATES.CONTINUOUS, - harvesterExitMaxAgeMs: agentConfig?.harvesterExitMaxAgeMs || 300000, - harvesterExitMaxSizeB: agentConfig?.harvesterExitMaxSizeB || 20971520, + callbackPort: callbackPortFromLabel ? parseInt(callbackPortFromLabel, 10) : undefined, + harvesterTemplate: harvesterTemplateFromLabel, + harvesterPeriodMs: parseDuration(harvesterPeriodFromLabel, 900000), + harvesterMaxFiles: harvesterMaxFilesFromLabel ? parseInt(harvesterMaxFilesFromLabel, 10) : 4, + harvesterExitMaxAgeMs: parseDuration(harvesterExitMaxAgeFromLabel, 300000), + harvesterExitMaxSizeB: harvesterExitMaxSizeFromLabel ? parseInt(harvesterExitMaxSizeFromLabel, 10) : 20971520, logLevel: logLevelFromLabel, }; setInitialFormData(newData); @@ -251,82 +269,62 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, }); } - function generateEnvVarPatches(containerIndex: number): Patch[] { - const patches: Patch[] = []; - const container = containers[containerIndex]; - const basePath = `/spec/template/spec/containers/${containerIndex}/env`; - - const envVarUpdates = [ - { name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: formData.harvesterTemplate }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_AGE_MS, value: formData.harvesterExitMaxAgeMs.toString() }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_SIZE_B, value: formData.harvesterExitMaxSizeB.toString() }, - ]; - - if (!container.env || container.env.length === 0) { - patches.push({ - op: 'add', - path: basePath, - value: [], - }); - } - - for (const envVar of envVarUpdates) { - const existingIndex = getEnvVarIndex(container, envVar.name); - - if (existingIndex !== -1) { - if (envVar.value) { - patches.push({ - op: 'replace', - path: `${basePath}/${existingIndex}/value`, - value: envVar.value, - }); - } else { - patches.push({ - op: 'remove', - path: `${basePath}/${existingIndex}`, - }); - } - } else if (envVar.value) { - patches.push({ - op: 'add', - path: `${basePath}/-`, - value: { - name: envVar.name, - value: envVar.value, - }, - }); - } - } - - return patches; - } - function addMetadataLabels(instance: K8sResourceCommon) { const patches: Patch[] = [ { op: 'replace', - path: '/spec/template/metadata/labels/cryostat.io~1name', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.NAME.replace('/', '~1')}`, value: instance.metadata?.name, }, { op: 'replace', - path: '/spec/template/metadata/labels/cryostat.io~1namespace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.NAMESPACE.replace('/', '~1')}`, value: instance.metadata?.namespace, }, { op: 'replace', - path: '/spec/template/metadata/labels/cryostat.io~1log-level', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.LOG_LEVEL.replace('/', '~1')}`, value: formData.logLevel, }, { op: 'replace', - path: '/spec/template/metadata/labels/cryostat.io~1java-options-var', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.JAVA_OPTIONS_VAR.replace('/', '~1')}`, value: formData.javaOptsVar, }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_TEMPLATE.replace('/', '~1')}`, + value: formData.harvesterTemplate, + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_PERIOD.replace('/', '~1')}`, + value: formatDurationForLabel(formData.harvesterPeriodMs), + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_MAX_FILES.replace('/', '~1')}`, + value: formData.harvesterMaxFiles.toString(), + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_AGE.replace('/', '~1')}`, + value: formatDurationForLabel(formData.harvesterExitMaxAgeMs), + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_SIZE.replace('/', '~1')}`, + value: formatByteSizeForLabel(formData.harvesterExitMaxSizeB), + }, ]; - const envVarPatches = generateEnvVarPatches(formData.selectedContainerIndex); - patches.push(...envVarPatches); + if (formData.callbackPort !== undefined) { + patches.push({ + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.CALLBACK_PORT.replace('/', '~1')}`, + value: formData.callbackPort.toString(), + }); + } patchResource(patches); } @@ -336,57 +334,64 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, const deploymentLabels = resource.spec?.template.metadata.labels; // Only remove labels that exist - if (deploymentLabels?.['cryostat.io/name']) { + if (deploymentLabels?.[AGENT_LABEL_KEYS.NAME]) { patches.push({ op: 'remove', - path: '/spec/template/metadata/labels/cryostat.io~1name', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.NAME.replace('/', '~1')}`, }); } - if (deploymentLabels?.['cryostat.io/namespace']) { + if (deploymentLabels?.[AGENT_LABEL_KEYS.NAMESPACE]) { patches.push({ op: 'remove', - path: '/spec/template/metadata/labels/cryostat.io~1namespace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.NAMESPACE.replace('/', '~1')}`, }); } - if (deploymentLabels?.['cryostat.io/log-level']) { + if (deploymentLabels?.[AGENT_LABEL_KEYS.LOG_LEVEL]) { patches.push({ op: 'remove', - path: '/spec/template/metadata/labels/cryostat.io~1log-level', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.LOG_LEVEL.replace('/', '~1')}`, }); } - if (deploymentLabels?.['cryostat.io/java-options-var']) { + if (deploymentLabels?.[AGENT_LABEL_KEYS.JAVA_OPTIONS_VAR]) { patches.push({ op: 'remove', - path: '/spec/template/metadata/labels/cryostat.io~1java-options-var', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.JAVA_OPTIONS_VAR.replace('/', '~1')}`, }); } - - // Also remove environment variables from the selected container - const container = containers[formData.selectedContainerIndex]; - const basePath = `/spec/template/spec/containers/${formData.selectedContainerIndex}/env`; - - const envVarsToRemove = [ - AGENT_ENV_VARS.HARVESTER_TEMPLATE, - AGENT_ENV_VARS.HARVESTER_EXIT_MAX_AGE_MS, - AGENT_ENV_VARS.HARVESTER_EXIT_MAX_SIZE_B, - ]; - - // Collect indices and sort in descending order to avoid index shifting issues - const indicesToRemove: number[] = []; - for (const envVarName of envVarsToRemove) { - const existingIndex = getEnvVarIndex(container, envVarName); - if (existingIndex !== -1) { - indicesToRemove.push(existingIndex); - } + if (deploymentLabels?.[AGENT_LABEL_KEYS.CALLBACK_PORT]) { + patches.push({ + op: 'remove', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.CALLBACK_PORT.replace('/', '~1')}`, + }); } - - // Sort descending so we remove from highest index first - indicesToRemove.sort((a, b) => b - a); - - for (const index of indicesToRemove) { + if (deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_TEMPLATE]) { patches.push({ op: 'remove', - path: `${basePath}/${index}`, + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_TEMPLATE.replace('/', '~1')}`, + }); + } + if (deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_PERIOD]) { + patches.push({ + op: 'remove', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_PERIOD.replace('/', '~1')}`, + }); + } + if (deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_MAX_FILES]) { + patches.push({ + op: 'remove', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_MAX_FILES.replace('/', '~1')}`, + }); + } + if (deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_AGE]) { + patches.push({ + op: 'remove', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_AGE.replace('/', '~1')}`, + }); + } + if (deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_SIZE]) { + patches.push({ + op: 'remove', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_SIZE.replace('/', '~1')}`, }); } @@ -398,14 +403,20 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, const hasInstanceChanged = formSelectValue !== initialValue || formData.cryostatInstance !== initialValue; const hasJavaOptsChanged = formData.javaOptsVar !== initialFormData.javaOptsVar; + const hasCallbackPortChanged = formData.callbackPort !== initialFormData.callbackPort; const hasHarvesterChanged = formData.harvesterTemplate !== initialFormData.harvesterTemplate; + const hasHarvesterPeriodChanged = formData.harvesterPeriodMs !== initialFormData.harvesterPeriodMs; + const hasHarvesterMaxFilesChanged = formData.harvesterMaxFiles !== initialFormData.harvesterMaxFiles; const hasHarvesterExitMaxAgeChanged = formData.harvesterExitMaxAgeMs !== initialFormData.harvesterExitMaxAgeMs; const hasHarvesterExitMaxSizeChanged = formData.harvesterExitMaxSizeB !== initialFormData.harvesterExitMaxSizeB; const hasLogLevelChanged = formData.logLevel !== initialFormData.logLevel; const hasAnyChange = hasInstanceChanged || hasJavaOptsChanged || + hasCallbackPortChanged || hasHarvesterChanged || + hasHarvesterPeriodChanged || + hasHarvesterMaxFilesChanged || hasHarvesterExitMaxAgeChanged || hasHarvesterExitMaxSizeChanged || hasLogLevelChanged; @@ -439,7 +450,10 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, selectedContainerIndex: 0, selectedContainerName: containers[0]?.name || '', javaOptsVar: 'JAVA_TOOL_OPTIONS', + callbackPort: undefined, harvesterTemplate: HARVESTER_TEMPLATES.CONTINUOUS, + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, harvesterExitMaxAgeMs: 300000, harvesterExitMaxSizeB: 20971520, logLevel: LOG_LEVELS.OFF, @@ -453,30 +467,58 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, const patches: Patch[] = [ { op: 'replace', - path: '/spec/template/metadata/labels/cryostat.io~1name', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.NAME.replace('/', '~1')}`, value: cryostatInstance.metadata?.name, }, { op: 'replace', - path: '/spec/template/metadata/labels/cryostat.io~1namespace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.NAMESPACE.replace('/', '~1')}`, value: cryostatInstance.metadata?.namespace, }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_TEMPLATE.replace('/', '~1')}`, + value: quickRegisterData.harvesterTemplate, + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_PERIOD.replace('/', '~1')}`, + value: formatDurationForLabel(quickRegisterData.harvesterPeriodMs), + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_MAX_FILES.replace('/', '~1')}`, + value: quickRegisterData.harvesterMaxFiles.toString(), + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_AGE.replace('/', '~1')}`, + value: formatDurationForLabel(quickRegisterData.harvesterExitMaxAgeMs), + }, + { + op: 'replace', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_SIZE.replace('/', '~1')}`, + value: formatByteSizeForLabel(quickRegisterData.harvesterExitMaxSizeB), + }, ]; - if (cryostatInstance.metadata?.labels?.['cryostat.io/log-level']) { + if (cryostatInstance.metadata?.labels?.[AGENT_LABEL_KEYS.LOG_LEVEL]) { patches.push({ op: 'remove', - path: '/spec/template/metadata/labels/cryostat.io~1log-level', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.LOG_LEVEL.replace('/', '~1')}`, }); } - if (cryostatInstance.metadata?.labels?.['cryostat.io/java-options-var']) { + if (cryostatInstance.metadata?.labels?.[AGENT_LABEL_KEYS.JAVA_OPTIONS_VAR]) { patches.push({ op: 'remove', - path: '/spec/template/metadata/labels/cryostat.io~1java-options-var', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.JAVA_OPTIONS_VAR.replace('/', '~1')}`, + }); + } + if (cryostatInstance.metadata?.labels?.[AGENT_LABEL_KEYS.CALLBACK_PORT]) { + patches.push({ + op: 'remove', + path: `/spec/template/metadata/labels/${AGENT_LABEL_KEYS.CALLBACK_PORT.replace('/', '~1')}`, }); } - - const envVarPatches = generateEnvVarPatchesForData(quickRegisterData); - patches.push(...envVarPatches); patchResource(patches); } @@ -487,71 +529,30 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, } }; - function generateEnvVarPatchesForData(data: WizardFormData): Patch[] { - const patches: Patch[] = []; - const container = containers[data.selectedContainerIndex]; - const basePath = `/spec/template/spec/containers/${data.selectedContainerIndex}/env`; - - const envVarUpdates = [ - { name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: data.harvesterTemplate }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_AGE_MS, value: data.harvesterExitMaxAgeMs.toString() }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_SIZE_B, value: data.harvesterExitMaxSizeB.toString() }, - ]; - - if (!container.env || container.env.length === 0) { - patches.push({ - op: 'add', - path: basePath, - value: [], - }); - } - - for (const envVar of envVarUpdates) { - const existingIndex = getEnvVarIndex(container, envVar.name); - - if (existingIndex !== -1) { - if (envVar.value) { - patches.push({ - op: 'replace', - path: `${basePath}/${existingIndex}/value`, - value: envVar.value, - }); - } else { - patches.push({ - op: 'remove', - path: `${basePath}/${existingIndex}`, - }); - } - } else if (envVar.value) { - patches.push({ - op: 'add', - path: `${basePath}/-`, - value: { - name: envVar.name, - value: envVar.value, - }, - }); - } - } - - return patches; - } - const handleContainerChange = (index: number) => { const container = containers[index]; - const agentConfig = getAgentConfig(container); const deploymentLabels = resource.spec?.template.metadata.labels; - const logLevelFromLabel = (deploymentLabels?.['cryostat.io/log-level'] as LogLevel) || LOG_LEVELS.OFF; - const javaOptsVarFromLabel = deploymentLabels?.['cryostat.io/java-options-var'] || 'JAVA_TOOL_OPTIONS'; + const logLevelFromLabel = (deploymentLabels?.[AGENT_LABEL_KEYS.LOG_LEVEL] as LogLevel) || LOG_LEVELS.OFF; + const javaOptsVarFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.JAVA_OPTIONS_VAR] || 'JAVA_TOOL_OPTIONS'; + const callbackPortFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.CALLBACK_PORT]; + const harvesterTemplateFromLabel = + (deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_TEMPLATE] as HarvesterTemplate) || HARVESTER_TEMPLATES.CONTINUOUS; + const harvesterPeriodFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_PERIOD]; + const harvesterMaxFilesFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_MAX_FILES]; + const harvesterExitMaxAgeFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_AGE]; + const harvesterExitMaxSizeFromLabel = deploymentLabels?.[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_SIZE]; setFormData((prev) => ({ ...prev, selectedContainerIndex: index, selectedContainerName: container.name, javaOptsVar: javaOptsVarFromLabel, - harvesterTemplate: agentConfig?.harvesterTemplate || HARVESTER_TEMPLATES.CONTINUOUS, - harvesterExitMaxAgeMs: agentConfig?.harvesterExitMaxAgeMs || 300000, - harvesterExitMaxSizeB: agentConfig?.harvesterExitMaxSizeB || 20971520, + callbackPort: callbackPortFromLabel ? parseInt(callbackPortFromLabel, 10) : undefined, + harvesterTemplate: harvesterTemplateFromLabel, + harvesterPeriodMs: parseDuration(harvesterPeriodFromLabel, 900000), + harvesterMaxFiles: harvesterMaxFilesFromLabel ? parseInt(harvesterMaxFilesFromLabel, 10) : 4, + harvesterExitMaxAgeMs: parseDuration(harvesterExitMaxAgeFromLabel, 300000), + harvesterExitMaxSizeB: harvesterExitMaxSizeFromLabel ? parseInt(harvesterExitMaxSizeFromLabel, 10) : 20971520, logLevel: logLevelFromLabel, })); }; @@ -560,10 +561,22 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, setFormData((prev) => ({ ...prev, javaOptsVar: value })); }; - const handleHarvesterChange = (template: HarvesterTemplate, maxAge: number, maxSize: number) => { + const handleCallbackPortChange = (value: number | undefined) => { + setFormData((prev) => ({ ...prev, callbackPort: value })); + }; + + const handleHarvesterChange = ( + template: HarvesterTemplate, + periodMs: number, + maxFiles: number, + maxAge: number, + maxSize: number, + ) => { setFormData((prev) => ({ ...prev, harvesterTemplate: template, + harvesterPeriodMs: periodMs, + harvesterMaxFiles: maxFiles, harvesterExitMaxAgeMs: maxAge, harvesterExitMaxSizeB: maxSize, })); @@ -649,13 +662,18 @@ export const DeploymentLabelActionModal: React.FC = ({ kind, /> - + = ({ kind, > = ({ kind, selectedInstance={selectedInstance} selectedContainer={selectedContainer} javaOptsVar={formData.javaOptsVar} + callbackPort={formData.callbackPort} harvesterTemplate={formData.harvesterTemplate} + harvesterPeriodMs={formData.harvesterPeriodMs} + harvesterMaxFiles={formData.harvesterMaxFiles} harvesterExitMaxAgeMs={formData.harvesterExitMaxAgeMs} harvesterExitMaxSizeB={formData.harvesterExitMaxSizeB} logLevel={formData.logLevel} diff --git a/src/openshift/actions/DeploymentLabelAction/HarvesterConfigStep.tsx b/src/openshift/actions/DeploymentLabelAction/HarvesterConfigStep.tsx index 981c50cf..cf9205a1 100644 --- a/src/openshift/actions/DeploymentLabelAction/HarvesterConfigStep.tsx +++ b/src/openshift/actions/DeploymentLabelAction/HarvesterConfigStep.tsx @@ -28,20 +28,24 @@ import { InputGroupItem, } from '@patternfly/react-core'; import * as React from 'react'; -import { HARVESTER_TEMPLATES, HarvesterTemplate } from './envVarUtils'; +import { HARVESTER_TEMPLATES, HarvesterTemplate } from './utils'; type TimeUnit = 'ms' | 's' | 'm' | 'h'; type SizeUnit = 'B' | 'KiB' | 'MiB' | 'GiB'; interface HarvesterConfigStepProps { harvesterTemplate: HarvesterTemplate; + harvesterPeriodMs: number; + harvesterMaxFiles: number; harvesterExitMaxAgeMs: number; harvesterExitMaxSizeB: number; - onChange: (template: HarvesterTemplate, maxAge: number, maxSize: number) => void; + onChange: (template: HarvesterTemplate, periodMs: number, maxFiles: number, maxAge: number, maxSize: number) => void; } export const HarvesterConfigStep: React.FC = ({ harvesterTemplate, + harvesterPeriodMs, + harvesterMaxFiles, harvesterExitMaxAgeMs, harvesterExitMaxSizeB, onChange, @@ -53,8 +57,6 @@ export const HarvesterConfigStep: React.FC = ({ const timeUnits: Array<{ divisor: number; unit: TimeUnit }> = [ { divisor: 60 * 60 * 1000, unit: 'h' }, { divisor: 60 * 1000, unit: 'm' }, - { divisor: 1000, unit: 's' }, - { divisor: 1, unit: 'ms' }, ]; for (const { divisor, unit } of timeUnits) { @@ -62,7 +64,8 @@ export const HarvesterConfigStep: React.FC = ({ return { value: ms / divisor, unit }; } } - return { value: ms, unit: 'ms' }; + // Default to minutes for period display + return { value: ms / (60 * 1000), unit: 'm' }; }; // Convert bytes to display value and unit @@ -82,10 +85,14 @@ export const HarvesterConfigStep: React.FC = ({ return { value: bytes, unit: 'B' }; }; + const [periodDisplay, setPeriodDisplay] = React.useState(() => getTimeDisplayValue(harvesterPeriodMs)); const [timeDisplay, setTimeDisplay] = React.useState(() => getTimeDisplayValue(harvesterExitMaxAgeMs)); const [sizeDisplay, setSizeDisplay] = React.useState(() => getSizeDisplayValue(harvesterExitMaxSizeB)); - // Update display when props change + React.useEffect(() => { + setPeriodDisplay(getTimeDisplayValue(harvesterPeriodMs)); + }, [harvesterPeriodMs]); + React.useEffect(() => { setTimeDisplay(getTimeDisplayValue(harvesterExitMaxAgeMs)); }, [harvesterExitMaxAgeMs]); @@ -95,7 +102,7 @@ export const HarvesterConfigStep: React.FC = ({ }, [harvesterExitMaxSizeB]); const handleTemplateChange = (template: HarvesterTemplate) => { - onChange(template, harvesterExitMaxAgeMs, harvesterExitMaxSizeB); + onChange(template, harvesterPeriodMs, harvesterMaxFiles, harvesterExitMaxAgeMs, harvesterExitMaxSizeB); }; const convertTimeToMs = (value: number, unit: TimeUnit): number => { @@ -104,10 +111,8 @@ export const HarvesterConfigStep: React.FC = ({ return value * 60 * 60 * 1000; case 'm': return value * 60 * 1000; - case 's': - return value * 1000; default: - return value; + return value * 60 * 1000; // Default to minutes } }; @@ -124,30 +129,47 @@ export const HarvesterConfigStep: React.FC = ({ } }; + const handlePeriodValueChange = (value: number) => { + setPeriodDisplay((prev) => ({ ...prev, value })); + const ms = convertTimeToMs(value, periodDisplay.unit); + onChange(harvesterTemplate, ms, harvesterMaxFiles, harvesterExitMaxAgeMs, harvesterExitMaxSizeB); + }; + + const handlePeriodUnitChange = (_event: React.FormEvent, unit: string) => { + const newUnit = unit as TimeUnit; + setPeriodDisplay((prev) => ({ ...prev, unit: newUnit })); + const ms = convertTimeToMs(periodDisplay.value, newUnit); + onChange(harvesterTemplate, ms, harvesterMaxFiles, harvesterExitMaxAgeMs, harvesterExitMaxSizeB); + }; + + const handleMaxFilesChange = (value: number) => { + onChange(harvesterTemplate, harvesterPeriodMs, value, harvesterExitMaxAgeMs, harvesterExitMaxSizeB); + }; + const handleTimeValueChange = (value: number) => { setTimeDisplay((prev) => ({ ...prev, value })); const ms = convertTimeToMs(value, timeDisplay.unit); - onChange(harvesterTemplate, ms, harvesterExitMaxSizeB); + onChange(harvesterTemplate, harvesterPeriodMs, harvesterMaxFiles, ms, harvesterExitMaxSizeB); }; const handleTimeUnitChange = (_event: React.FormEvent, unit: string) => { const newUnit = unit as TimeUnit; setTimeDisplay((prev) => ({ ...prev, unit: newUnit })); const ms = convertTimeToMs(timeDisplay.value, newUnit); - onChange(harvesterTemplate, ms, harvesterExitMaxSizeB); + onChange(harvesterTemplate, harvesterPeriodMs, harvesterMaxFiles, ms, harvesterExitMaxSizeB); }; const handleSizeValueChange = (value: number) => { setSizeDisplay((prev) => ({ ...prev, value })); const bytes = convertSizeToBytes(value, sizeDisplay.unit); - onChange(harvesterTemplate, harvesterExitMaxAgeMs, bytes); + onChange(harvesterTemplate, harvesterPeriodMs, harvesterMaxFiles, harvesterExitMaxAgeMs, bytes); }; const handleSizeUnitChange = (_event: React.FormEvent, unit: string) => { const newUnit = unit as SizeUnit; setSizeDisplay((prev) => ({ ...prev, unit: newUnit })); const bytes = convertSizeToBytes(sizeDisplay.value, newUnit); - onChange(harvesterTemplate, harvesterExitMaxAgeMs, bytes); + onChange(harvesterTemplate, harvesterPeriodMs, harvesterMaxFiles, harvesterExitMaxAgeMs, bytes); }; return ( @@ -178,6 +200,63 @@ export const HarvesterConfigStep: React.FC = ({ onChange={() => handleTemplateChange(HARVESTER_TEMPLATES.PROFILING)} /> + + + + handlePeriodValueChange(Math.max(0, periodDisplay.value - 1))} + onPlus={() => handlePeriodValueChange(periodDisplay.value + 1)} + onChange={(event) => { + const value = Number((event.target as HTMLInputElement).value); + if (!isNaN(value) && value >= 0) { + handlePeriodValueChange(value); + } + }} + min={0} + widthChars={10} + /> + + + + + + + + + + + {t('DEPLOYMENT_ACTION_HARVESTER_PERIOD_HELPER')} + + + + + handleMaxFilesChange(Math.max(1, harvesterMaxFiles - 1))} + onPlus={() => handleMaxFilesChange(harvesterMaxFiles + 1)} + onChange={(event) => { + const value = Number((event.target as HTMLInputElement).value); + if (!isNaN(value) && value >= 1) { + handleMaxFilesChange(value); + } + }} + min={1} + widthChars={10} + /> + + + {t('DEPLOYMENT_ACTION_HARVESTER_MAX_FILES_HELPER')} + + + diff --git a/src/openshift/actions/DeploymentLabelAction/JavaOptsConfigStep.tsx b/src/openshift/actions/DeploymentLabelAction/JavaOptsConfigStep.tsx deleted file mode 100644 index 1aff4997..00000000 --- a/src/openshift/actions/DeploymentLabelAction/JavaOptsConfigStep.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright The Cryostat Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { useCryostatTranslation } from '@i18n/i18nextUtil'; -import { Form, FormGroup, TextInput, FormHelperText, HelperText, HelperTextItem } from '@patternfly/react-core'; -import * as React from 'react'; - -interface JavaOptsConfigStepProps { - javaOptsVar: string; - onChange: (value: string) => void; -} - -export const JavaOptsConfigStep: React.FC = ({ javaOptsVar, onChange }) => { - const { t } = useCryostatTranslation(); - - return ( -
- - onChange(value)} - placeholder="JAVA_TOOL_OPTIONS" - /> - - - {t('DEPLOYMENT_ACTION_JAVA_OPTS_VAR_HELPER')} - - - -
- ); -}; diff --git a/src/openshift/actions/DeploymentLabelAction/LogLevelConfigStep.tsx b/src/openshift/actions/DeploymentLabelAction/LogLevelConfigStep.tsx index 0423692a..341a9bf9 100644 --- a/src/openshift/actions/DeploymentLabelAction/LogLevelConfigStep.tsx +++ b/src/openshift/actions/DeploymentLabelAction/LogLevelConfigStep.tsx @@ -16,7 +16,7 @@ import { useCryostatTranslation } from '@i18n/i18nextUtil'; import { Form, FormGroup, Radio } from '@patternfly/react-core'; import * as React from 'react'; -import { LOG_LEVELS, LogLevel } from './envVarUtils'; +import { LOG_LEVELS, LogLevel } from './utils'; interface LogLevelConfigStepProps { logLevel: LogLevel; diff --git a/src/openshift/actions/DeploymentLabelAction/ReviewStep.tsx b/src/openshift/actions/DeploymentLabelAction/ReviewStep.tsx index d31ee8d4..70f5c364 100644 --- a/src/openshift/actions/DeploymentLabelAction/ReviewStep.tsx +++ b/src/openshift/actions/DeploymentLabelAction/ReviewStep.tsx @@ -23,26 +23,32 @@ import { DescriptionListDescription, } from '@patternfly/react-core'; import * as React from 'react'; -import { Container, HarvesterTemplate, LogLevel } from './envVarUtils'; +import { Container, HarvesterTemplate, LogLevel } from './utils'; interface ReviewStepProps { selectedInstance: K8sResourceKind | null; selectedContainer: Container | null; harvesterTemplate: HarvesterTemplate; + harvesterPeriodMs: number; + harvesterMaxFiles: number; harvesterExitMaxAgeMs: number; harvesterExitMaxSizeB: number; logLevel: LogLevel; javaOptsVar: string; + callbackPort?: number; } export const ReviewStep: React.FC = ({ selectedInstance, selectedContainer, harvesterTemplate, + harvesterPeriodMs, + harvesterMaxFiles, harvesterExitMaxAgeMs, harvesterExitMaxSizeB, logLevel, javaOptsVar, + callbackPort, }) => { const { t } = useCryostatTranslation(); @@ -73,10 +79,24 @@ export const ReviewStep: React.FC = ({ {t('DEPLOYMENT_ACTION_REVIEW_JAVA_OPTS_VAR')} {javaOptsVar} + + {t('DEPLOYMENT_ACTION_REVIEW_CALLBACK_PORT')} + + {callbackPort !== undefined ? callbackPort.toString() : ''} + + {t('DEPLOYMENT_ACTION_REVIEW_HARVESTER')} {getHarvesterDisplayName(harvesterTemplate)} + + {t('DEPLOYMENT_ACTION_REVIEW_HARVESTER_PERIOD')} + {formatDuration(harvesterPeriodMs, 1)} + + + {t('DEPLOYMENT_ACTION_REVIEW_HARVESTER_MAX_FILES')} + {harvesterMaxFiles} + {t('DEPLOYMENT_ACTION_REVIEW_HARVESTER_EXIT_MAX_AGE')} {formatDuration(harvesterExitMaxAgeMs, 1)} diff --git a/src/openshift/actions/DeploymentLabelAction/envVarUtils.ts b/src/openshift/actions/DeploymentLabelAction/envVarUtils.ts deleted file mode 100644 index 6572fcfb..00000000 --- a/src/openshift/actions/DeploymentLabelAction/envVarUtils.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright The Cryostat Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface EnvVar { - name: string; - value?: string; - valueFrom?: unknown; -} - -export interface Container { - name: string; - image: string; - env?: EnvVar[]; -} - -export const AGENT_ENV_VARS = { - HARVESTER_TEMPLATE: 'CRYOSTAT_AGENT_HARVESTER_TEMPLATE', - HARVESTER_PERIOD_MS: 'CRYOSTAT_AGENT_HARVESTER_PERIOD_MS', - HARVESTER_MAX_FILES: 'CRYOSTAT_AGENT_HARVESTER_MAX_FILES', - HARVESTER_EXIT_MAX_AGE_MS: 'CRYOSTAT_AGENT_HARVESTER_EXIT_MAX_AGE_MS', - HARVESTER_EXIT_MAX_SIZE_B: 'CRYOSTAT_AGENT_HARVESTER_EXIT_MAX_SIZE_B', -} as const; - -export const HARVESTER_TEMPLATES = { - NONE: '', - CONTINUOUS: 'Continuous', - PROFILING: 'Profiling', -} as const; - -export const LOG_LEVELS = { - OFF: 'off', - ERROR: 'error', - WARN: 'warn', - INFO: 'info', - DEBUG: 'debug', - TRACE: 'trace', -} as const; - -export type HarvesterTemplate = (typeof HARVESTER_TEMPLATES)[keyof typeof HARVESTER_TEMPLATES]; -export type LogLevel = (typeof LOG_LEVELS)[keyof typeof LOG_LEVELS]; - -export interface AgentConfig { - harvesterTemplate: HarvesterTemplate; - harvesterExitMaxAgeMs: number; - harvesterExitMaxSizeB: number; -} - -export function findEnvVar(container: Container, envVarName: string): EnvVar | undefined { - return container.env?.find((env) => env.name === envVarName); -} - -export function getAgentConfig(container: Container): AgentConfig | null { - const harvesterTemplateVar = findEnvVar(container, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - const harvesterExitMaxAgeVar = findEnvVar(container, AGENT_ENV_VARS.HARVESTER_EXIT_MAX_AGE_MS); - const harvesterExitMaxSizeVar = findEnvVar(container, AGENT_ENV_VARS.HARVESTER_EXIT_MAX_SIZE_B); - - if (!harvesterTemplateVar && !harvesterExitMaxAgeVar && !harvesterExitMaxSizeVar) { - return null; - } - - return { - harvesterTemplate: (harvesterTemplateVar?.value as HarvesterTemplate) || HARVESTER_TEMPLATES.NONE, - harvesterExitMaxAgeMs: harvesterExitMaxAgeVar?.value ? parseInt(harvesterExitMaxAgeVar.value, 10) : 300000, - harvesterExitMaxSizeB: harvesterExitMaxSizeVar?.value ? parseInt(harvesterExitMaxSizeVar.value, 10) : 20971520, - }; -} - -export function formatAgentConfig(config: AgentConfig | null, logLevel?: LogLevel, javaOptsVar?: string): string { - if (!config && !logLevel && !javaOptsVar) { - return 'None'; - } - - const parts: string[] = []; - if (config?.harvesterTemplate) { - parts.push(`Harvester=${config.harvesterTemplate}`); - } - if (logLevel) { - parts.push(`LogLevel=${logLevel.toUpperCase()}`); - } - if (javaOptsVar && javaOptsVar !== 'JAVA_TOOL_OPTIONS') { - parts.push(`JavaOpts=${javaOptsVar}`); - } - - return parts.length > 0 ? parts.join(', ') : 'None'; -} - -export function getEnvVarIndex(container: Container, envVarName: string): number { - return container.env?.findIndex((env) => env.name === envVarName) ?? -1; -} - -export function hasEnvVar(container: Container, envVarName: string): boolean { - return getEnvVarIndex(container, envVarName) !== -1; -} diff --git a/src/openshift/actions/DeploymentLabelAction/utils.ts b/src/openshift/actions/DeploymentLabelAction/utils.ts new file mode 100644 index 00000000..8fa33c1d --- /dev/null +++ b/src/openshift/actions/DeploymentLabelAction/utils.ts @@ -0,0 +1,180 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const AGENT_AUTOCONFIG_LABEL_PREFIX = 'cryostat.io/'; + +export const AGENT_LABEL_KEYS = { + NAME: `${AGENT_AUTOCONFIG_LABEL_PREFIX}name`, + NAMESPACE: `${AGENT_AUTOCONFIG_LABEL_PREFIX}namespace`, + LOG_LEVEL: `${AGENT_AUTOCONFIG_LABEL_PREFIX}log-level`, + JAVA_OPTIONS_VAR: `${AGENT_AUTOCONFIG_LABEL_PREFIX}java-options-var`, + CALLBACK_PORT: `${AGENT_AUTOCONFIG_LABEL_PREFIX}callback-port`, + HARVESTER_TEMPLATE: `${AGENT_AUTOCONFIG_LABEL_PREFIX}harvester-template`, + HARVESTER_PERIOD: `${AGENT_AUTOCONFIG_LABEL_PREFIX}harvester-period`, + HARVESTER_MAX_FILES: `${AGENT_AUTOCONFIG_LABEL_PREFIX}harvester-max-files`, + HARVESTER_EXIT_MAX_AGE: `${AGENT_AUTOCONFIG_LABEL_PREFIX}harvester-exit-max-age`, + HARVESTER_EXIT_MAX_SIZE: `${AGENT_AUTOCONFIG_LABEL_PREFIX}harvester-exit-max-size`, +} as const; + +export interface Container { + name: string; + image: string; + labels?: Record; +} + +export const HARVESTER_TEMPLATES = { + NONE: '', + CONTINUOUS: 'Continuous', + PROFILING: 'Profiling', +} as const; + +export const LOG_LEVELS = { + OFF: 'off', + ERROR: 'error', + WARN: 'warn', + INFO: 'info', + DEBUG: 'debug', + TRACE: 'trace', +} as const; + +export type HarvesterTemplate = (typeof HARVESTER_TEMPLATES)[keyof typeof HARVESTER_TEMPLATES]; +export type LogLevel = (typeof LOG_LEVELS)[keyof typeof LOG_LEVELS]; + +export interface AgentConfig { + harvesterTemplate: HarvesterTemplate; + harvesterPeriodMs: number; + harvesterMaxFiles: number; + harvesterExitMaxAgeMs: number; + harvesterExitMaxSizeB: number; +} + +export function parseDuration(duration: string | undefined, defaultValue: number): number { + if (!duration) return defaultValue; + const match = duration.match(/^(\d+)(ms|s|m|h)?$/); + if (!match) return defaultValue; + const value = parseInt(match[1], 10); + const unit = match[2] || 'ms'; + switch (unit) { + case 'h': + return value * 60 * 60 * 1000; + case 'm': + return value * 60 * 1000; + case 's': + return value * 1000; + default: + return value; + } +} + +export function parseByteSize(size: string | undefined, defaultValue: number): number { + if (!size) return defaultValue; + const match = size.match(/^(\d+)(Gi|Mi|Ki)?$/); + if (!match) return defaultValue; + const value = parseInt(match[1], 10); + const unit = match[2]; + switch (unit) { + case 'Gi': + return value * 1024 * 1024 * 1024; + case 'Mi': + return value * 1024 * 1024; + case 'Ki': + return value * 1024; + default: + return value; + } +} + +export function formatDurationForLabel(durationMs: number): string { + const hours = durationMs / (60 * 60 * 1000); + if (Number.isInteger(hours) && hours >= 1) { + return `${hours}h`; + } + + const minutes = durationMs / (60 * 1000); + if (Number.isInteger(minutes) && minutes >= 1) { + return `${minutes}m`; + } + + const seconds = durationMs / 1000; + if (Number.isInteger(seconds) && seconds >= 1) { + return `${seconds}s`; + } + + return `${durationMs}ms`; +} + +export function formatByteSizeForLabel(bytes: number): string { + const gi = bytes / (1024 * 1024 * 1024); + if (Number.isInteger(gi) && gi >= 1) { + return `${gi}Gi`; + } + + const mi = bytes / (1024 * 1024); + if (Number.isInteger(mi) && mi >= 1) { + return `${mi}Mi`; + } + + const ki = bytes / 1024; + if (Number.isInteger(ki) && ki >= 1) { + return `${ki}Ki`; + } + + return `${bytes}`; +} + +export function getAgentConfig(container: Container): AgentConfig | null { + const labels = container.labels; + if (!labels) { + return null; + } + + const harvesterTemplate = labels[AGENT_LABEL_KEYS.HARVESTER_TEMPLATE]; + const harvesterPeriod = labels[AGENT_LABEL_KEYS.HARVESTER_PERIOD]; + const harvesterMaxFiles = labels[AGENT_LABEL_KEYS.HARVESTER_MAX_FILES]; + const harvesterExitMaxAge = labels[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_AGE]; + const harvesterExitMaxSize = labels[AGENT_LABEL_KEYS.HARVESTER_EXIT_MAX_SIZE]; + + if (!harvesterTemplate && !harvesterPeriod && !harvesterMaxFiles && !harvesterExitMaxAge && !harvesterExitMaxSize) { + return null; + } + + return { + harvesterTemplate: (harvesterTemplate as HarvesterTemplate) || HARVESTER_TEMPLATES.NONE, + harvesterPeriodMs: parseDuration(harvesterPeriod, 900000), + harvesterMaxFiles: harvesterMaxFiles ? parseInt(harvesterMaxFiles, 10) : 4, + harvesterExitMaxAgeMs: parseDuration(harvesterExitMaxAge, 300000), + harvesterExitMaxSizeB: parseByteSize(harvesterExitMaxSize, 20971520), + }; +} + +export function formatAgentConfig(config: AgentConfig | null, logLevel?: LogLevel, javaOptsVar?: string): string { + if (!config && !logLevel && !javaOptsVar) { + return 'None'; + } + + const parts: string[] = []; + if (config?.harvesterTemplate) { + parts.push(`Harvester=${config.harvesterTemplate}`); + } + if (logLevel) { + parts.push(`LogLevel=${logLevel.toUpperCase()}`); + } + if (javaOptsVar && javaOptsVar !== 'JAVA_TOOL_OPTIONS') { + parts.push(`JavaOpts=${javaOptsVar}`); + } + + return parts.length > 0 ? parts.join(', ') : 'None'; +} diff --git a/src/openshift/test/actions/DeploymentLabelAction/ContainerSelectionStep.test.tsx b/src/openshift/test/actions/DeploymentLabelAction/ContainerSelectionStep.test.tsx index 2c3b97d8..dfbb0c61 100644 --- a/src/openshift/test/actions/DeploymentLabelAction/ContainerSelectionStep.test.tsx +++ b/src/openshift/test/actions/DeploymentLabelAction/ContainerSelectionStep.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { ContainerSelectionStep } from '@console-plugin/actions/DeploymentLabelAction/ContainerSelectionStep'; -import { Container, AGENT_ENV_VARS, LOG_LEVELS } from '@console-plugin/actions/DeploymentLabelAction/envVarUtils'; +import { Container, LOG_LEVELS } from '@console-plugin/actions/DeploymentLabelAction/utils'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; @@ -32,7 +32,7 @@ describe('ContainerSelectionStep', () => { { name: 'app-container', image: 'quay.io/app:latest', - env: [], + labels: {}, }, ]; @@ -40,17 +40,19 @@ describe('ContainerSelectionStep', () => { { name: 'app-container', image: 'quay.io/app:latest', - env: [{ name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: 'Continuous' }], + labels: { + 'cryostat.io/harvester-template': 'Continuous', + }, }, { name: 'sidecar-container', image: 'quay.io/sidecar:v1', - env: [], + labels: {}, }, { name: 'worker-container', image: 'quay.io/worker:latest', - env: [], + labels: {}, }, ]; diff --git a/src/openshift/test/actions/DeploymentLabelAction/DeploymentLabelActionModal.test.tsx b/src/openshift/test/actions/DeploymentLabelAction/DeploymentLabelActionModal.test.tsx index 4b5fff70..bcdb6df3 100644 --- a/src/openshift/test/actions/DeploymentLabelAction/DeploymentLabelActionModal.test.tsx +++ b/src/openshift/test/actions/DeploymentLabelAction/DeploymentLabelActionModal.test.tsx @@ -220,4 +220,140 @@ describe('DeploymentLabelActionModal', () => { expect(screen.getByText('DEPLOYMENT_ACTION_QUICK_REGISTER')).toBeInTheDocument(); }); }); + + it('should include callback port label when set in wizard', async () => { + renderModal(mockDeploymentWithoutLabels); + + const select = screen.getByLabelText('Cryostat Instance Selection'); + await userEvent.selectOptions(select, '0'); + await waitFor(() => { + expect(select).toHaveValue('0'); + }); + + // Navigate through wizard steps to Agent Configuration + let nextButton = screen.queryByRole('button', { name: /next/i }); + while (nextButton) { + await userEvent.click(nextButton); + // Wait a bit for the step to change + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check if we're on the Agent Configuration step by looking for the callback port field + const callbackPortInput = screen.queryByRole('spinbutton', { name: '' }); + if (callbackPortInput) { + // Found the callback port field, set its value + await userEvent.clear(callbackPortInput); + await userEvent.type(callbackPortInput, '8080'); + break; + } + + nextButton = screen.queryByRole('button', { name: /next/i }); + } + + // Continue to finish + nextButton = screen.queryByRole('button', { name: /next/i }); + while (nextButton) { + await userEvent.click(nextButton); + nextButton = screen.queryByRole('button', { name: /next/i }); + } + + const finishButton = screen.queryByRole('button', { name: /finish|submit/i }); + if (finishButton) { + await userEvent.click(finishButton); + expect(k8sPatchResource).toHaveBeenCalled(); + + const callArgs = (k8sPatchResource as jest.Mock).mock.calls[0][0]; + const patches = callArgs.patches; + + // Verify callback port patch is included + expect(patches).toEqual( + expect.arrayContaining([ + { + op: 'replace', + path: '/spec/template/metadata/labels/cryostat.io~1callback-port', + value: '8080', + }, + ]), + ); + } + }); + + it('should NOT include callback port label in Quick Register', async () => { + renderModal(mockDeploymentWithoutLabels); + + const select = screen.getByLabelText('Cryostat Instance Selection'); + await userEvent.selectOptions(select, '0'); + await waitFor(() => { + expect(select).toHaveValue('0'); + }); + + // Click Quick Register button + const quickRegisterButton = screen.getByText('DEPLOYMENT_ACTION_QUICK_REGISTER'); + await userEvent.click(quickRegisterButton); + + await waitFor(() => { + expect(k8sPatchResource).toHaveBeenCalled(); + }); + + const callArgs = (k8sPatchResource as jest.Mock).mock.calls[0][0]; + const patches = callArgs.patches; + + // Verify callback port patch is NOT included + const hasCallbackPortPatch = patches.some((p) => p.path?.includes('callback-port')); + expect(hasCallbackPortPatch).toBe(false); + }); + + it('should remove callback port label when deployment has it and user selects empty option', async () => { + const deploymentWithCallbackPort = { + ...mockDeploymentWithLabels, + spec: { + ...mockDeploymentWithLabels.spec, + template: { + ...mockDeploymentWithLabels.spec?.template, + metadata: { + ...mockDeploymentWithLabels.spec?.template?.metadata, + labels: { + ...mockDeploymentWithLabels.spec?.template?.metadata?.labels, + 'cryostat.io/callback-port': '8080', + }, + }, + }, + }, + }; + + renderModal(deploymentWithCallbackPort); + + const selectElement = screen.getByLabelText('Cryostat Instance Selection'); + expect(selectElement).toHaveValue('0'); + + await userEvent.selectOptions(selectElement, '-1'); + await waitFor(() => { + expect(selectElement).toHaveValue('-1'); + }); + + const nextButtons = screen.queryAllByRole('button', { name: /next/i }); + if (nextButtons.length > 0) { + for (const button of nextButtons) { + await userEvent.click(button); + } + } + + const finishButton = screen.queryByRole('button', { name: /finish|submit/i }); + if (finishButton) { + await userEvent.click(finishButton); + expect(k8sPatchResource).toHaveBeenCalled(); + + const callArgs = (k8sPatchResource as jest.Mock).mock.calls[0][0]; + const patches = callArgs.patches; + + // Verify callback port removal patch is included + expect(patches).toEqual( + expect.arrayContaining([ + { + op: 'remove', + path: '/spec/template/metadata/labels/cryostat.io~1callback-port', + }, + ]), + ); + } + }); }); diff --git a/src/openshift/test/actions/DeploymentLabelAction/envVarUtils.test.ts b/src/openshift/test/actions/DeploymentLabelAction/envVarUtils.test.ts deleted file mode 100644 index e931a605..00000000 --- a/src/openshift/test/actions/DeploymentLabelAction/envVarUtils.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright The Cryostat Authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { - Container, - AGENT_ENV_VARS, - HARVESTER_TEMPLATES, - LOG_LEVELS, - findEnvVar, - getAgentConfig, - formatAgentConfig, - getEnvVarIndex, - hasEnvVar, -} from '@console-plugin/actions/DeploymentLabelAction/envVarUtils'; - -describe('envVarUtils', () => { - const mockContainerWithConfig: Container = { - name: 'app-container', - image: 'quay.io/app:latest', - env: [ - { name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: 'Continuous' }, - { name: 'OTHER_VAR', value: 'other-value' }, - ], - }; - - const mockContainerWithoutConfig: Container = { - name: 'app-container', - image: 'quay.io/app:latest', - env: [{ name: 'OTHER_VAR', value: 'other-value' }], - }; - - const mockContainerNoEnv: Container = { - name: 'app-container', - image: 'quay.io/app:latest', - }; - - describe('findEnvVar', () => { - it('should find an existing environment variable', () => { - const result = findEnvVar(mockContainerWithConfig, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toEqual({ name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: 'Continuous' }); - }); - - it('should return undefined for non-existent environment variable', () => { - const result = findEnvVar(mockContainerWithoutConfig, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBeUndefined(); - }); - - it('should return undefined for container without env array', () => { - const result = findEnvVar(mockContainerNoEnv, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBeUndefined(); - }); - }); - - describe('getAgentConfig', () => { - it('should return agent config when env vars are present', () => { - const result = getAgentConfig(mockContainerWithConfig); - expect(result).toEqual({ - harvesterTemplate: 'Continuous', - harvesterExitMaxAgeMs: 300000, - harvesterExitMaxSizeB: 20971520, - }); - }); - - it('should return null when no agent env vars are present', () => { - const result = getAgentConfig(mockContainerWithoutConfig); - expect(result).toBeNull(); - }); - - it('should return config with defaults when only one env var is present', () => { - const containerWithPartialConfig: Container = { - name: 'app-container', - image: 'quay.io/app:latest', - env: [{ name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: 'Profiling' }], - }; - const result = getAgentConfig(containerWithPartialConfig); - expect(result).toEqual({ - harvesterTemplate: 'Profiling', - harvesterExitMaxAgeMs: 300000, - harvesterExitMaxSizeB: 20971520, - }); - }); - - it('should parse custom harvester exit max age when specified', () => { - const containerWithCustomAge: Container = { - name: 'app-container', - image: 'quay.io/app:latest', - env: [ - { name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: 'Continuous' }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_AGE_MS, value: '60000' }, - ], - }; - const result = getAgentConfig(containerWithCustomAge); - expect(result).toEqual({ - harvesterTemplate: 'Continuous', - harvesterExitMaxAgeMs: 60000, - harvesterExitMaxSizeB: 20971520, - }); - }); - - it('should parse custom harvester exit max size when specified', () => { - const containerWithCustomSize: Container = { - name: 'app-container', - image: 'quay.io/app:latest', - env: [ - { name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: 'Continuous' }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_SIZE_B, value: '52428800' }, - ], - }; - const result = getAgentConfig(containerWithCustomSize); - expect(result).toEqual({ - harvesterTemplate: 'Continuous', - harvesterExitMaxAgeMs: 300000, - harvesterExitMaxSizeB: 52428800, - }); - }); - - it('should parse all custom values when specified', () => { - const containerWithAllCustom: Container = { - name: 'app-container', - image: 'quay.io/app:latest', - env: [ - { name: AGENT_ENV_VARS.HARVESTER_TEMPLATE, value: 'Profiling' }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_AGE_MS, value: '45000' }, - { name: AGENT_ENV_VARS.HARVESTER_EXIT_MAX_SIZE_B, value: '31457280' }, - ], - }; - const result = getAgentConfig(containerWithAllCustom); - expect(result).toEqual({ - harvesterTemplate: 'Profiling', - harvesterExitMaxAgeMs: 45000, - harvesterExitMaxSizeB: 31457280, - }); - }); - - it('should return null for container without env array', () => { - const result = getAgentConfig(mockContainerNoEnv); - expect(result).toBeNull(); - }); - }); - - describe('formatAgentConfig', () => { - it('should format config with harvester and log level', () => { - const config = { - harvesterTemplate: HARVESTER_TEMPLATES.CONTINUOUS, - harvesterExitMaxAgeMs: 300000, - harvesterExitMaxSizeB: 20971520, - }; - const result = formatAgentConfig(config, LOG_LEVELS.INFO); - expect(result).toBe('Harvester=Continuous, LogLevel=INFO'); - }); - - it('should format config with only harvester template', () => { - const config = { - harvesterTemplate: HARVESTER_TEMPLATES.PROFILING, - harvesterExitMaxAgeMs: 300000, - harvesterExitMaxSizeB: 20971520, - }; - const result = formatAgentConfig(config); - expect(result).toBe('Harvester=Profiling'); - }); - - it('should format config with only log level', () => { - const result = formatAgentConfig(null, LOG_LEVELS.DEBUG); - expect(result).toBe('LogLevel=DEBUG'); - }); - - it('should format config with custom Java opts var', () => { - const config = { - harvesterTemplate: HARVESTER_TEMPLATES.CONTINUOUS, - harvesterExitMaxAgeMs: 300000, - harvesterExitMaxSizeB: 20971520, - }; - const result = formatAgentConfig(config, LOG_LEVELS.INFO, 'CUSTOM_JAVA_OPTS'); - expect(result).toBe('Harvester=Continuous, LogLevel=INFO, JavaOpts=CUSTOM_JAVA_OPTS'); - }); - - it('should return "None" for null config and no log level', () => { - const result = formatAgentConfig(null); - expect(result).toBe('None'); - }); - - it('should return "None" for empty config', () => { - const config = { - harvesterTemplate: '' as any, - harvesterExitMaxAgeMs: 300000, - harvesterExitMaxSizeB: 20971520, - }; - const result = formatAgentConfig(config); - expect(result).toBe('None'); - }); - }); - - describe('getEnvVarIndex', () => { - it('should return correct index for existing env var', () => { - const result = getEnvVarIndex(mockContainerWithConfig, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBe(0); - }); - - it('should return -1 for non-existent env var', () => { - const result = getEnvVarIndex(mockContainerWithoutConfig, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBe(-1); - }); - - it('should return -1 for container without env array', () => { - const result = getEnvVarIndex(mockContainerNoEnv, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBe(-1); - }); - }); - - describe('hasEnvVar', () => { - it('should return true for existing env var', () => { - const result = hasEnvVar(mockContainerWithConfig, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBe(true); - }); - - it('should return false for non-existent env var', () => { - const result = hasEnvVar(mockContainerWithoutConfig, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBe(false); - }); - - it('should return false for container without env array', () => { - const result = hasEnvVar(mockContainerNoEnv, AGENT_ENV_VARS.HARVESTER_TEMPLATE); - expect(result).toBe(false); - }); - }); -}); diff --git a/src/openshift/test/actions/DeploymentLabelAction/utils.test.ts b/src/openshift/test/actions/DeploymentLabelAction/utils.test.ts new file mode 100644 index 00000000..ad9f9b77 --- /dev/null +++ b/src/openshift/test/actions/DeploymentLabelAction/utils.test.ts @@ -0,0 +1,456 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Container, + HARVESTER_TEMPLATES, + LOG_LEVELS, + getAgentConfig, + formatAgentConfig, + formatDurationForLabel, + formatByteSizeForLabel, + parseDuration, + parseByteSize, +} from '@console-plugin/actions/DeploymentLabelAction/utils'; + +describe('envVarUtils', () => { + const mockContainerWithConfig: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Continuous', + }, + }; + + const mockContainerWithoutConfig: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: {}, + }; + + const mockContainerNoLabels: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + }; + + describe('getAgentConfig', () => { + it('should return agent config when labels are present', () => { + const result = getAgentConfig(mockContainerWithConfig); + expect(result).toEqual({ + harvesterTemplate: 'Continuous', + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }); + }); + + it('should return null when no agent labels are present', () => { + const result = getAgentConfig(mockContainerWithoutConfig); + expect(result).toBeNull(); + }); + + it('should return config with defaults when only one label is present', () => { + const containerWithPartialConfig: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Profiling', + }, + }; + const result = getAgentConfig(containerWithPartialConfig); + expect(result).toEqual({ + harvesterTemplate: 'Profiling', + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }); + }); + + it('should parse custom harvester period when specified', () => { + const containerWithCustomPeriod: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Continuous', + 'cryostat.io/harvester-period': '600000ms', + }, + }; + const result = getAgentConfig(containerWithCustomPeriod); + expect(result).toEqual({ + harvesterTemplate: 'Continuous', + harvesterPeriodMs: 600000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }); + }); + + it('should parse custom harvester max files when specified', () => { + const containerWithCustomMaxFiles: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Continuous', + 'cryostat.io/harvester-max-files': '8', + }, + }; + const result = getAgentConfig(containerWithCustomMaxFiles); + expect(result).toEqual({ + harvesterTemplate: 'Continuous', + harvesterPeriodMs: 900000, + harvesterMaxFiles: 8, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }); + }); + + it('should parse custom harvester exit max age when specified', () => { + const containerWithCustomAge: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Continuous', + 'cryostat.io/harvester-exit-max-age': '60000ms', + }, + }; + const result = getAgentConfig(containerWithCustomAge); + expect(result).toEqual({ + harvesterTemplate: 'Continuous', + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 60000, + harvesterExitMaxSizeB: 20971520, + }); + }); + + it('should parse custom harvester exit max size when specified', () => { + const containerWithCustomSize: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Continuous', + 'cryostat.io/harvester-exit-max-size': '52428800', + }, + }; + const result = getAgentConfig(containerWithCustomSize); + expect(result).toEqual({ + harvesterTemplate: 'Continuous', + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 52428800, + }); + }); + + it('should parse harvester exit max size with Kubernetes quantity suffix', () => { + const containerWithKubernetesSize: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Continuous', + 'cryostat.io/harvester-exit-max-size': '20Mi', + }, + }; + const result = getAgentConfig(containerWithKubernetesSize); + expect(result).toEqual({ + harvesterTemplate: 'Continuous', + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, // 20 * 1024 * 1024 + }); + }); + + it('should parse all custom values when specified', () => { + const containerWithAllCustom: Container = { + name: 'app-container', + image: 'quay.io/app:latest', + labels: { + 'cryostat.io/harvester-template': 'Profiling', + 'cryostat.io/harvester-period': '1200000ms', + 'cryostat.io/harvester-max-files': '6', + 'cryostat.io/harvester-exit-max-age': '45000ms', + 'cryostat.io/harvester-exit-max-size': '31457280', + }, + }; + const result = getAgentConfig(containerWithAllCustom); + expect(result).toEqual({ + harvesterTemplate: 'Profiling', + harvesterPeriodMs: 1200000, + harvesterMaxFiles: 6, + harvesterExitMaxAgeMs: 45000, + harvesterExitMaxSizeB: 31457280, + }); + }); + + it('should return null for container without labels', () => { + const result = getAgentConfig(mockContainerNoLabels); + expect(result).toBeNull(); + }); + }); + + describe('formatAgentConfig', () => { + it('should format config with harvester and log level', () => { + const config = { + harvesterTemplate: HARVESTER_TEMPLATES.CONTINUOUS, + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }; + const result = formatAgentConfig(config, LOG_LEVELS.INFO); + expect(result).toBe('Harvester=Continuous, LogLevel=INFO'); + }); + + it('should format config with only harvester template', () => { + const config = { + harvesterTemplate: HARVESTER_TEMPLATES.PROFILING, + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }; + const result = formatAgentConfig(config); + expect(result).toBe('Harvester=Profiling'); + }); + + it('should format config with only log level', () => { + const result = formatAgentConfig(null, LOG_LEVELS.DEBUG); + expect(result).toBe('LogLevel=DEBUG'); + }); + + it('should format config with custom Java opts var', () => { + const config = { + harvesterTemplate: HARVESTER_TEMPLATES.CONTINUOUS, + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }; + const result = formatAgentConfig(config, LOG_LEVELS.INFO, 'CUSTOM_JAVA_OPTS'); + expect(result).toBe('Harvester=Continuous, LogLevel=INFO, JavaOpts=CUSTOM_JAVA_OPTS'); + }); + + it('should return "None" for null config and no log level', () => { + const result = formatAgentConfig(null); + expect(result).toBe('None'); + }); + + it('should return "None" for empty config', () => { + const config = { + harvesterTemplate: '' as any, + harvesterPeriodMs: 900000, + harvesterMaxFiles: 4, + harvesterExitMaxAgeMs: 300000, + harvesterExitMaxSizeB: 20971520, + }; + const result = formatAgentConfig(config); + expect(result).toBe('None'); + }); + }); + + describe('parseDuration', () => { + it('should parse duration with hours unit', () => { + expect(parseDuration('2h', 0)).toBe(2 * 60 * 60 * 1000); + }); + + it('should parse duration with minutes unit', () => { + expect(parseDuration('15m', 0)).toBe(15 * 60 * 1000); + }); + + it('should parse duration with seconds unit', () => { + expect(parseDuration('30s', 0)).toBe(30 * 1000); + }); + + it('should parse duration with milliseconds unit', () => { + expect(parseDuration('500ms', 0)).toBe(500); + }); + + it('should parse duration without unit as milliseconds', () => { + expect(parseDuration('1000', 0)).toBe(1000); + }); + + it('should return default value for undefined duration', () => { + expect(parseDuration(undefined, 12345)).toBe(12345); + }); + + it('should return default value for invalid duration format', () => { + expect(parseDuration('invalid', 12345)).toBe(12345); + }); + + it('should return default value for empty string', () => { + expect(parseDuration('', 12345)).toBe(12345); + }); + }); + + describe('formatDurationForLabel', () => { + it('should format duration in hours when value divides evenly', () => { + expect(formatDurationForLabel(3600000)).toBe('1h'); + expect(formatDurationForLabel(7200000)).toBe('2h'); + expect(formatDurationForLabel(10800000)).toBe('3h'); + }); + + it('should format duration in minutes when value divides evenly', () => { + expect(formatDurationForLabel(60000)).toBe('1m'); + expect(formatDurationForLabel(900000)).toBe('15m'); + expect(formatDurationForLabel(1800000)).toBe('30m'); + }); + + it('should format duration in seconds when value divides evenly', () => { + expect(formatDurationForLabel(1000)).toBe('1s'); + expect(formatDurationForLabel(5000)).toBe('5s'); + expect(formatDurationForLabel(30000)).toBe('30s'); + }); + + it('should format duration in milliseconds when value does not divide evenly', () => { + expect(formatDurationForLabel(1500)).toBe('1500ms'); + expect(formatDurationForLabel(999)).toBe('999ms'); + expect(formatDurationForLabel(12345)).toBe('12345ms'); + }); + + it('should prefer larger units over smaller units', () => { + // 1 hour should be formatted as 1h, not 60m + expect(formatDurationForLabel(3600000)).toBe('1h'); + // 1 minute should be formatted as 1m, not 60s + expect(formatDurationForLabel(60000)).toBe('1m'); + }); + + it('should handle zero duration', () => { + expect(formatDurationForLabel(0)).toBe('0ms'); + }); + + it('should handle very large durations', () => { + expect(formatDurationForLabel(86400000)).toBe('24h'); // 24 hours + }); + + it('should format default harvester period correctly', () => { + expect(formatDurationForLabel(900000)).toBe('15m'); + }); + + it('should format default harvester exit max age correctly', () => { + expect(formatDurationForLabel(300000)).toBe('5m'); + }); + }); + + describe('formatByteSizeForLabel', () => { + it('should format size in Gi when value divides evenly', () => { + expect(formatByteSizeForLabel(1073741824)).toBe('1Gi'); + expect(formatByteSizeForLabel(2147483648)).toBe('2Gi'); + expect(formatByteSizeForLabel(5368709120)).toBe('5Gi'); + }); + + it('should format size in Mi when value divides evenly', () => { + expect(formatByteSizeForLabel(1048576)).toBe('1Mi'); + expect(formatByteSizeForLabel(20971520)).toBe('20Mi'); + expect(formatByteSizeForLabel(52428800)).toBe('50Mi'); + }); + + it('should format size in Ki when value divides evenly', () => { + expect(formatByteSizeForLabel(1024)).toBe('1Ki'); + expect(formatByteSizeForLabel(2048)).toBe('2Ki'); + expect(formatByteSizeForLabel(10240)).toBe('10Ki'); + }); + + it('should format size in bytes when value does not divide evenly', () => { + expect(formatByteSizeForLabel(500)).toBe('500'); + expect(formatByteSizeForLabel(1023)).toBe('1023'); + expect(formatByteSizeForLabel(1025)).toBe('1025'); + }); + + it('should prefer larger units over smaller units', () => { + // 1 GiB should be formatted as 1Gi, not 1024Mi + expect(formatByteSizeForLabel(1073741824)).toBe('1Gi'); + // 1 MiB should be formatted as 1Mi, not 1024Ki + expect(formatByteSizeForLabel(1048576)).toBe('1Mi'); + }); + + it('should handle zero size', () => { + expect(formatByteSizeForLabel(0)).toBe('0'); + }); + + it('should handle very large sizes', () => { + expect(formatByteSizeForLabel(10737418240)).toBe('10Gi'); // 10 GiB + }); + + it('should format default harvester exit max size correctly', () => { + expect(formatByteSizeForLabel(20971520)).toBe('20Mi'); + }); + + it('should use Kubernetes resource format (no B suffix)', () => { + // Verify the format matches k8s resource quantities + expect(formatByteSizeForLabel(1073741824)).not.toContain('B'); + expect(formatByteSizeForLabel(1048576)).not.toContain('B'); + expect(formatByteSizeForLabel(1024)).not.toContain('B'); + }); + + it('should handle fractional MiB that do not divide evenly', () => { + // 1.5 MiB in bytes (1572864) should not format as Mi + expect(formatByteSizeForLabel(1572864)).toBe('1536Ki'); + }); + + it('should handle fractional GiB that do not divide evenly', () => { + // 1.5 GiB in bytes (1610612736) should not format as Gi + expect(formatByteSizeForLabel(1610612736)).toBe('1536Mi'); + }); + }); + + describe('parseByteSize', () => { + it('should parse size with Gi unit', () => { + expect(parseByteSize('2Gi', 0)).toBe(2 * 1024 * 1024 * 1024); + }); + + it('should parse size with Mi unit', () => { + expect(parseByteSize('20Mi', 0)).toBe(20 * 1024 * 1024); + }); + + it('should parse size with Ki unit', () => { + expect(parseByteSize('512Ki', 0)).toBe(512 * 1024); + }); + + it('should parse size without unit as bytes', () => { + expect(parseByteSize('1024', 0)).toBe(1024); + }); + + it('should return default value for undefined size', () => { + expect(parseByteSize(undefined, 12345)).toBe(12345); + }); + + it('should return default value for invalid size format', () => { + expect(parseByteSize('invalid', 12345)).toBe(12345); + }); + + it('should return default value for empty string', () => { + expect(parseByteSize('', 12345)).toBe(12345); + }); + + it('should parse default harvester exit max size correctly', () => { + expect(parseByteSize('20Mi', 0)).toBe(20971520); + }); + + it('should handle size with only numeric value', () => { + expect(parseByteSize('52428800', 0)).toBe(52428800); + }); + + it('should parse 1Gi correctly', () => { + expect(parseByteSize('1Gi', 0)).toBe(1073741824); + }); + + it('should parse 50Mi correctly', () => { + expect(parseByteSize('50Mi', 0)).toBe(52428800); + }); + }); +});