|
| 1 | +import io.fabric8.kubernetes.api.model.Quantity; |
| 2 | + |
| 3 | +import java.math.BigDecimal; |
| 4 | +import java.math.RoundingMode; |
| 5 | +import java.util.ArrayList; |
| 6 | +import java.util.List; |
| 7 | +import java.util.Map; |
| 8 | + |
| 9 | +/** |
| 10 | + * Shared utilities for CRD schema introspection and resource path resolution. |
| 11 | + * |
| 12 | + * <p>Used by both {@code VerifyResourceLimits} and {@code VerifyDocumentedResources} |
| 13 | + * to discover {@code ResourceRequirements} fields in CRD OpenAPI v3 schemas |
| 14 | + * and resolve those paths against CR instances. |
| 15 | + */ |
| 16 | +public class CrdSchemaUtils { |
| 17 | + |
| 18 | + private CrdSchemaUtils() { } |
| 19 | + |
| 20 | + /** |
| 21 | + * Extract the kind name from a CRD document. |
| 22 | + */ |
| 23 | + static String extractCrdKind(Map<String, Object> crd) { |
| 24 | + Map<String, Object> names = getNestedMap(crd, "spec", "names"); |
| 25 | + return names != null ? (String) names.get("kind") : null; |
| 26 | + } |
| 27 | + |
| 28 | + /** |
| 29 | + * Extract the OpenAPI v3 schema from the first version of a CRD. |
| 30 | + */ |
| 31 | + @SuppressWarnings("unchecked") |
| 32 | + static Map<String, Object> extractCrdSchema(Map<String, Object> crd) { |
| 33 | + Map<String, Object> spec = getMap(crd, "spec"); |
| 34 | + if (spec == null) return null; |
| 35 | + |
| 36 | + Object versionsObj = spec.get("versions"); |
| 37 | + if (!(versionsObj instanceof List)) return null; |
| 38 | + |
| 39 | + List<?> versions = (List<?>) versionsObj; |
| 40 | + if (versions.isEmpty()) return null; |
| 41 | + |
| 42 | + Object firstVersion = versions.get(0); |
| 43 | + if (!(firstVersion instanceof Map)) return null; |
| 44 | + |
| 45 | + return getNestedMap((Map<String, Object>) firstVersion, "schema", "openAPIV3Schema"); |
| 46 | + } |
| 47 | + |
| 48 | + /** |
| 49 | + * Recursively walk a CRD schema to find all ResourceRequirements fields. |
| 50 | + * Records the JSON path for each field found. |
| 51 | + * |
| 52 | + * <p>This method finds ALL ResourceRequirements fields without filtering. |
| 53 | + * Callers that need to skip certain paths (e.g., pod-level overhead fields |
| 54 | + * inside embedded PodTemplateSpecs) should filter the results using |
| 55 | + * {@link #isPodSpecOverheadPath(String)}. |
| 56 | + */ |
| 57 | + @SuppressWarnings("unchecked") |
| 58 | + static void walkSchema(Map<String, Object> schemaNode, String currentPath, List<String> result) { |
| 59 | + if (schemaNode == null) return; |
| 60 | + |
| 61 | + Map<String, Object> properties = getMap(schemaNode, "properties"); |
| 62 | + if (properties == null) return; |
| 63 | + |
| 64 | + if (isResourceRequirements(properties)) { |
| 65 | + result.add(currentPath); |
| 66 | + return; |
| 67 | + } |
| 68 | + |
| 69 | + for (Map.Entry<String, Object> entry : properties.entrySet()) { |
| 70 | + if (!(entry.getValue() instanceof Map)) continue; |
| 71 | + |
| 72 | + Map<String, Object> childSchema = (Map<String, Object>) entry.getValue(); |
| 73 | + String childPath = currentPath + "." + entry.getKey(); |
| 74 | + String type = (String) childSchema.get("type"); |
| 75 | + |
| 76 | + if ("array".equals(type)) { |
| 77 | + Map<String, Object> items = getMap(childSchema, "items"); |
| 78 | + if (items != null) { |
| 79 | + walkSchema(items, childPath + "[]", result); |
| 80 | + } |
| 81 | + } else { |
| 82 | + walkSchema(childSchema, childPath, result); |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + /** |
| 88 | + * Detect a ResourceRequirements field by its OpenAPI schema signature. |
| 89 | + * Must have "limits" and "requests" properties where both have |
| 90 | + * additionalProperties with x-kubernetes-int-or-string: true. |
| 91 | + */ |
| 92 | + static boolean isResourceRequirements(Map<String, Object> properties) { |
| 93 | + if (!properties.containsKey("limits") || !properties.containsKey("requests")) { |
| 94 | + return false; |
| 95 | + } |
| 96 | + |
| 97 | + return hasIntOrStringAdditionalProperties(properties.get("limits")) |
| 98 | + && hasIntOrStringAdditionalProperties(properties.get("requests")); |
| 99 | + } |
| 100 | + |
| 101 | + @SuppressWarnings("unchecked") |
| 102 | + private static boolean hasIntOrStringAdditionalProperties(Object fieldObj) { |
| 103 | + if (!(fieldObj instanceof Map)) return false; |
| 104 | + Map<String, Object> field = (Map<String, Object>) fieldObj; |
| 105 | + Object addProps = field.get("additionalProperties"); |
| 106 | + if (!(addProps instanceof Map)) return false; |
| 107 | + return Boolean.TRUE.equals(((Map<String, Object>) addProps).get("x-kubernetes-int-or-string")); |
| 108 | + } |
| 109 | + |
| 110 | + /** |
| 111 | + * Check whether a ResourceRequirements path represents a pod-level |
| 112 | + * overhead field embedded in a PodTemplateSpec, rather than a |
| 113 | + * component-level resource requirement. |
| 114 | + * |
| 115 | + * <p>Pod-level resources (added in k8s 1.30) appear as siblings of |
| 116 | + * {@code containers} inside embedded PodSpec structures like |
| 117 | + * {@code template.spec} or {@code podTemplateSpec.spec}. These are |
| 118 | + * infrastructure overhead and should not be required in CR configs. |
| 119 | + * |
| 120 | + * <p>CRD-level resources (e.g., Prometheus {@code spec.resources}) that |
| 121 | + * happen to be siblings of {@code containers} are NOT filtered by this |
| 122 | + * method — they appear at the CRD spec level, not inside an embedded |
| 123 | + * PodTemplateSpec. |
| 124 | + * |
| 125 | + * @param path the dot-separated path (e.g., ".spec.app.podTemplateSpec.spec.resources") |
| 126 | + * @return true if this is a pod-level overhead path that should be skipped |
| 127 | + */ |
| 128 | + static boolean isPodSpecOverheadPath(String path) { |
| 129 | + return path.matches(".*\\.template\\.spec\\.resources$") |
| 130 | + || path.matches(".*\\.podTemplateSpec\\.spec\\.resources$"); |
| 131 | + } |
| 132 | + |
| 133 | + /** |
| 134 | + * Resolve a path through a document, handling array segments (ending with []). |
| 135 | + * Returns all leaf values reached along with their resolved paths. |
| 136 | + */ |
| 137 | + @SuppressWarnings("unchecked") |
| 138 | + static List<ResolvedNode> resolvePath(Object current, String[] segments, int index, String pathSoFar) { |
| 139 | + if (index >= segments.length) { |
| 140 | + return List.of(new ResolvedNode(pathSoFar, current)); |
| 141 | + } |
| 142 | + |
| 143 | + String segment = segments[index]; |
| 144 | + |
| 145 | + if (segment.endsWith("[]")) { |
| 146 | + String key = segment.substring(0, segment.length() - 2); |
| 147 | + if (!(current instanceof Map)) return List.of(); |
| 148 | + Object listObj = ((Map<String, Object>) current).get(key); |
| 149 | + if (!(listObj instanceof List)) return List.of(); |
| 150 | + |
| 151 | + List<?> list = (List<?>) listObj; |
| 152 | + List<ResolvedNode> results = new ArrayList<>(); |
| 153 | + for (int i = 0; i < list.size(); i++) { |
| 154 | + results.addAll(resolvePath(list.get(i), segments, index + 1, |
| 155 | + pathSoFar + "." + key + "[" + i + "]")); |
| 156 | + } |
| 157 | + return results; |
| 158 | + } else { |
| 159 | + if (!(current instanceof Map)) return List.of(); |
| 160 | + Object child = ((Map<String, Object>) current).get(segment); |
| 161 | + if (child == null) return List.of(); |
| 162 | + return resolvePath(child, segments, index + 1, pathSoFar + "." + segment); |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + // --- Kubernetes quantity parsing (delegates to Fabric8 Quantity) --- |
| 167 | + |
| 168 | + private static final BigDecimal MILLIS_PER_CORE = BigDecimal.valueOf(1000); |
| 169 | + private static final BigDecimal BYTES_PER_MIB = BigDecimal.valueOf(1_048_576); |
| 170 | + |
| 171 | + /** |
| 172 | + * Parse a Kubernetes CPU quantity to millicores. |
| 173 | + * |
| 174 | + * <p>Handles all Kubernetes quantity formats via Fabric8 {@link Quantity}, |
| 175 | + * including millicore suffixes ({@code "500m"}), whole/fractional cores |
| 176 | + * ({@code "1"}, {@code "0.5"}), and values parsed by SnakeYAML as |
| 177 | + * {@link Integer} or {@link Double}. |
| 178 | + * |
| 179 | + * @param value the CPU quantity (String, Integer, or Double) |
| 180 | + * @return the value in millicores |
| 181 | + */ |
| 182 | + static long parseCpuMillis(Object value) { |
| 183 | + Quantity q = Quantity.parse(String.valueOf(value)); |
| 184 | + return q.getNumericalAmount().multiply(MILLIS_PER_CORE).longValue(); |
| 185 | + } |
| 186 | + |
| 187 | + /** |
| 188 | + * Parse a Kubernetes memory quantity to MiB. |
| 189 | + * |
| 190 | + * <p>Handles all Kubernetes quantity formats via Fabric8 {@link Quantity}, |
| 191 | + * including binary suffixes ({@code Ki}, {@code Mi}, {@code Gi}, {@code Ti}, |
| 192 | + * {@code Pi}, {@code Ei}), decimal suffixes ({@code k}, {@code M}, {@code G}, |
| 193 | + * {@code T}, {@code P}, {@code E}), exponent notation, and plain byte counts. |
| 194 | + * |
| 195 | + * @param value the memory quantity (String, Integer, or Double) |
| 196 | + * @return the value in MiB (rounded half-up) |
| 197 | + */ |
| 198 | + static long parseMemoryMiB(Object value) { |
| 199 | + Quantity q = Quantity.parse(String.valueOf(value)); |
| 200 | + BigDecimal bytes = Quantity.getAmountInBytes(q); |
| 201 | + return bytes.divide(BYTES_PER_MIB, 0, RoundingMode.HALF_UP).longValue(); |
| 202 | + } |
| 203 | + |
| 204 | + /** |
| 205 | + * Check that {@code resources.requests} does not exceed {@code resources.limits} |
| 206 | + * for both CPU and memory. |
| 207 | + * |
| 208 | + * <p>Uses numeric comparison via Fabric8 {@link Quantity} so semantically |
| 209 | + * equal values in different formats (e.g., {@code "1"} vs {@code "1000m"}) |
| 210 | + * are treated as equal. |
| 211 | + * |
| 212 | + * @param resources the resources map (with "requests" and "limits" sub-maps) |
| 213 | + * @param prefix a human-readable prefix for error messages |
| 214 | + * @return list of invariant violation messages (empty if requests <= limits) |
| 215 | + */ |
| 216 | + @SuppressWarnings("unchecked") |
| 217 | + static List<String> checkRequestsNotExceedLimits(Map<String, Object> resources, String prefix) { |
| 218 | + List<String> errors = new ArrayList<>(); |
| 219 | + if (resources == null) return errors; |
| 220 | + |
| 221 | + Object requestsObj = resources.get("requests"); |
| 222 | + Object limitsObj = resources.get("limits"); |
| 223 | + if (!(requestsObj instanceof Map) || !(limitsObj instanceof Map)) return errors; |
| 224 | + |
| 225 | + Map<String, Object> requests = (Map<String, Object>) requestsObj; |
| 226 | + Map<String, Object> limits = (Map<String, Object>) limitsObj; |
| 227 | + |
| 228 | + if (requests.containsKey("cpu") && limits.containsKey("cpu")) { |
| 229 | + long reqCpu = parseCpuMillis(requests.get("cpu")); |
| 230 | + long limCpu = parseCpuMillis(limits.get("cpu")); |
| 231 | + if (reqCpu > limCpu) { |
| 232 | + errors.add(prefix + " requests.cpu (" + reqCpu |
| 233 | + + "m) > limits.cpu (" + limCpu + "m)"); |
| 234 | + } |
| 235 | + } |
| 236 | + if (requests.containsKey("memory") && limits.containsKey("memory")) { |
| 237 | + long reqMem = parseMemoryMiB(requests.get("memory")); |
| 238 | + long limMem = parseMemoryMiB(limits.get("memory")); |
| 239 | + if (reqMem > limMem) { |
| 240 | + errors.add(prefix + " requests.memory (" + reqMem |
| 241 | + + "Mi) > limits.memory (" + limMem + "Mi)"); |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + return errors; |
| 246 | + } |
| 247 | + |
| 248 | + // --- Utilities --- |
| 249 | + |
| 250 | + @SuppressWarnings("unchecked") |
| 251 | + static Map<String, Object> getMap(Map<String, Object> parent, String key) { |
| 252 | + Object value = parent.get(key); |
| 253 | + return value instanceof Map ? (Map<String, Object>) value : null; |
| 254 | + } |
| 255 | + |
| 256 | + static Map<String, Object> getNestedMap(Map<String, Object> root, String... keys) { |
| 257 | + Map<String, Object> current = root; |
| 258 | + for (String key : keys) { |
| 259 | + current = getMap(current, key); |
| 260 | + if (current == null) return null; |
| 261 | + } |
| 262 | + return current; |
| 263 | + } |
| 264 | + |
| 265 | + static class ResolvedNode { |
| 266 | + final String path; |
| 267 | + final Object value; |
| 268 | + |
| 269 | + ResolvedNode(String path, Object value) { |
| 270 | + this.path = path; |
| 271 | + this.value = value; |
| 272 | + } |
| 273 | + } |
| 274 | +} |
0 commit comments