|
| 1 | +import type { InitialWizardFormData } from './types'; |
| 2 | +import type { Deployment } from '../../../extension-points'; |
| 3 | + |
| 4 | +export enum ExtractionIncompleteReason { |
| 5 | + MISSING_MODEL_TYPE = 'missing-model-type', |
| 6 | + MISSING_CONNECTION = 'missing-connection', |
| 7 | + AUTOSCALING_NOT_SUPPORTED = 'autoscaling-not-supported', |
| 8 | + TOLERATIONS_NOT_SUPPORTED = 'tolerations-not-supported', |
| 9 | + NODE_SELECTOR_NOT_SUPPORTED = 'node-selector-not-supported', |
| 10 | + MULTIPLE_CONTAINERS_NOT_SUPPORTED = 'multiple-containers-not-supported', |
| 11 | + CUSTOM_ROUTER_NOT_SUPPORTED = 'custom-router-not-supported', |
| 12 | + MISSING_CRITICAL_FIELD = 'missing-critical-field', |
| 13 | + EXTRACTION_ERROR = 'extraction-error', |
| 14 | +} |
| 15 | + |
| 16 | +export type ExtractionIncompleteDetail = { |
| 17 | + reason: ExtractionIncompleteReason; |
| 18 | + message: string; |
| 19 | + fieldName?: string; |
| 20 | +}; |
| 21 | + |
| 22 | +export type ExtractionValidationResult = { |
| 23 | + isComplete: boolean; |
| 24 | + details: ExtractionIncompleteDetail[]; |
| 25 | +}; |
| 26 | + |
| 27 | +/** |
| 28 | + * Validates whether a deployment's extracted form data is complete and parseable. |
| 29 | + * Returns details about any missing or unsupported configurations that would prevent |
| 30 | + * proper form display. |
| 31 | + * |
| 32 | + * @param deployment - The deployment being edited |
| 33 | + * @param extractedFormData - The form data extracted from the deployment |
| 34 | + * @param extractionError - Any error that occurred during extraction |
| 35 | + * @returns Validation result with completeness status and details |
| 36 | + */ |
| 37 | +export const validateExtractionCompleteness = ( |
| 38 | + deployment: Deployment | undefined | null, |
| 39 | + extractedFormData: InitialWizardFormData | undefined, |
| 40 | + extractionError: Error | undefined, |
| 41 | +): ExtractionValidationResult => { |
| 42 | + const details: ExtractionIncompleteDetail[] = []; |
| 43 | + |
| 44 | + // If there's no deployment, extraction is "complete" (new deployment) |
| 45 | + if (!deployment) { |
| 46 | + return { isComplete: true, details: [] }; |
| 47 | + } |
| 48 | + |
| 49 | + // If there was an extraction error, mark as incomplete |
| 50 | + if (extractionError) { |
| 51 | + details.push({ |
| 52 | + reason: ExtractionIncompleteReason.EXTRACTION_ERROR, |
| 53 | + message: `Failed to extract deployment data: ${extractionError.message}`, |
| 54 | + }); |
| 55 | + return { isComplete: false, details }; |
| 56 | + } |
| 57 | + |
| 58 | + // If extraction produced no data, mark as incomplete |
| 59 | + if (!extractedFormData) { |
| 60 | + details.push({ |
| 61 | + reason: ExtractionIncompleteReason.EXTRACTION_ERROR, |
| 62 | + message: 'No form data could be extracted from deployment', |
| 63 | + }); |
| 64 | + return { isComplete: false, details }; |
| 65 | + } |
| 66 | + |
| 67 | + // Check for missing model type annotation |
| 68 | + if (!extractedFormData.modelTypeField) { |
| 69 | + details.push({ |
| 70 | + reason: ExtractionIncompleteReason.MISSING_MODEL_TYPE, |
| 71 | + message: |
| 72 | + 'Missing model type annotation (opendatahub.io/model-type). The wizard requires this to determine if the model is predictive or generative.', |
| 73 | + fieldName: 'modelTypeField', |
| 74 | + }); |
| 75 | + } |
| 76 | + |
| 77 | + // Check for missing connection when model location type is EXISTING |
| 78 | + if ( |
| 79 | + extractedFormData.modelLocationData?.type === 'existing' && |
| 80 | + !extractedFormData.modelLocationData.connection |
| 81 | + ) { |
| 82 | + details.push({ |
| 83 | + reason: ExtractionIncompleteReason.MISSING_CONNECTION, |
| 84 | + message: |
| 85 | + 'Missing connection annotation. The deployment references an existing connection but the annotation is missing.', |
| 86 | + fieldName: 'modelLocationData.connection', |
| 87 | + }); |
| 88 | + } |
| 89 | + |
| 90 | + // Check for KServe autoscaling (minReplicas !== maxReplicas) |
| 91 | + // For KServe deployments, check if the deployment has different min/max replicas |
| 92 | + if (deployment.model.kind === 'InferenceService' && 'spec' in deployment.model) { |
| 93 | + const { spec } = deployment.model; |
| 94 | + if ( |
| 95 | + spec && |
| 96 | + typeof spec === 'object' && |
| 97 | + 'predictor' in spec && |
| 98 | + spec.predictor && |
| 99 | + typeof spec.predictor === 'object' |
| 100 | + ) { |
| 101 | + const { predictor } = spec; |
| 102 | + const minReplicas = 'minReplicas' in predictor ? predictor.minReplicas : undefined; |
| 103 | + const maxReplicas = 'maxReplicas' in predictor ? predictor.maxReplicas : undefined; |
| 104 | + |
| 105 | + if ( |
| 106 | + typeof minReplicas === 'number' && |
| 107 | + typeof maxReplicas === 'number' && |
| 108 | + minReplicas !== maxReplicas |
| 109 | + ) { |
| 110 | + details.push({ |
| 111 | + reason: ExtractionIncompleteReason.AUTOSCALING_NOT_SUPPORTED, |
| 112 | + message: `Autoscaling is configured (minReplicas: ${minReplicas}, maxReplicas: ${maxReplicas}) but is not supported in the wizard form.`, |
| 113 | + fieldName: 'numReplicas', |
| 114 | + }); |
| 115 | + } |
| 116 | + |
| 117 | + // Check for KServe tolerations |
| 118 | + if ( |
| 119 | + 'tolerations' in predictor && |
| 120 | + Array.isArray(predictor.tolerations) && |
| 121 | + predictor.tolerations.length > 0 |
| 122 | + ) { |
| 123 | + details.push({ |
| 124 | + reason: ExtractionIncompleteReason.TOLERATIONS_NOT_SUPPORTED, |
| 125 | + message: `Tolerations are configured (${predictor.tolerations.length} toleration(s)) but are not supported in the wizard form.`, |
| 126 | + fieldName: 'hardwareProfile', |
| 127 | + }); |
| 128 | + } |
| 129 | + |
| 130 | + // Check for KServe node selectors |
| 131 | + if ( |
| 132 | + 'nodeSelector' in predictor && |
| 133 | + predictor.nodeSelector && |
| 134 | + typeof predictor.nodeSelector === 'object' && |
| 135 | + Object.keys(predictor.nodeSelector).length > 0 |
| 136 | + ) { |
| 137 | + details.push({ |
| 138 | + reason: ExtractionIncompleteReason.NODE_SELECTOR_NOT_SUPPORTED, |
| 139 | + message: `Node selectors are configured but are not supported in the wizard form.`, |
| 140 | + fieldName: 'hardwareProfile', |
| 141 | + }); |
| 142 | + } |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + // Check for LLMD-specific unsupported configurations |
| 147 | + if (deployment.model.kind === 'LLMInferenceService' && 'spec' in deployment.model) { |
| 148 | + const { spec } = deployment.model; |
| 149 | + if (spec && typeof spec === 'object') { |
| 150 | + // Check for multiple containers |
| 151 | + if ('template' in spec && spec.template && typeof spec.template === 'object') { |
| 152 | + const { template } = spec; |
| 153 | + if ( |
| 154 | + 'containers' in template && |
| 155 | + Array.isArray(template.containers) && |
| 156 | + template.containers.length > 1 |
| 157 | + ) { |
| 158 | + details.push({ |
| 159 | + reason: ExtractionIncompleteReason.MULTIPLE_CONTAINERS_NOT_SUPPORTED, |
| 160 | + message: `Multiple containers (${template.containers.length}) are configured. The wizard form only supports a single main container.`, |
| 161 | + fieldName: 'template.containers', |
| 162 | + }); |
| 163 | + } |
| 164 | + |
| 165 | + // Check for LLMD tolerations |
| 166 | + if ( |
| 167 | + 'tolerations' in template && |
| 168 | + Array.isArray(template.tolerations) && |
| 169 | + template.tolerations.length > 0 |
| 170 | + ) { |
| 171 | + details.push({ |
| 172 | + reason: ExtractionIncompleteReason.TOLERATIONS_NOT_SUPPORTED, |
| 173 | + message: `Tolerations are configured (${template.tolerations.length} toleration(s)) but are not supported in the wizard form.`, |
| 174 | + fieldName: 'hardwareProfile', |
| 175 | + }); |
| 176 | + } |
| 177 | + |
| 178 | + // Check for LLMD node selectors |
| 179 | + if ( |
| 180 | + 'nodeSelector' in template && |
| 181 | + template.nodeSelector && |
| 182 | + typeof template.nodeSelector === 'object' && |
| 183 | + Object.keys(template.nodeSelector).length > 0 |
| 184 | + ) { |
| 185 | + details.push({ |
| 186 | + reason: ExtractionIncompleteReason.NODE_SELECTOR_NOT_SUPPORTED, |
| 187 | + message: `Node selectors are configured but are not supported in the wizard form.`, |
| 188 | + fieldName: 'hardwareProfile', |
| 189 | + }); |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + // Check for custom router configuration |
| 194 | + if ('router' in spec && spec.router && typeof spec.router === 'object') { |
| 195 | + // Check if router has any non-empty configuration |
| 196 | + const hasCustomRouter = Object.entries(spec.router).some(([, value]) => { |
| 197 | + // Check if the value is not undefined, null, or an empty object |
| 198 | + if (value == null) { |
| 199 | + return false; |
| 200 | + } |
| 201 | + if (typeof value === 'object' && Object.keys(value).length === 0) { |
| 202 | + return false; |
| 203 | + } |
| 204 | + return true; |
| 205 | + }); |
| 206 | + |
| 207 | + if (hasCustomRouter) { |
| 208 | + details.push({ |
| 209 | + reason: ExtractionIncompleteReason.CUSTOM_ROUTER_NOT_SUPPORTED, |
| 210 | + message: |
| 211 | + 'Custom router configuration is present but is not supported in the wizard form.', |
| 212 | + fieldName: 'router', |
| 213 | + }); |
| 214 | + } |
| 215 | + } |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + return { |
| 220 | + isComplete: details.length === 0, |
| 221 | + details, |
| 222 | + }; |
| 223 | +}; |
0 commit comments