Skip to content

Commit 3b23a30

Browse files
committed
Add resource-limit verification, overlay docs, and CI improvements │
│ Add CI scripts that verify every container in an overlay has resource │ requests and limits, and that overlay documentation pages declare │ accurate resource totals. Add documentation for the core overlay and │ a guide for overlay contributors. │ │ * Add VerifyResourceLimits script to check all containers and CR │ resource fields have requests and limits set │ * Add VerifyDocumentedResources script to check documented cpu_total │ and memory_total match kustomize build output │ * Add CrdSchemaUtils shared utility for CRD schema introspection │ * Add unit tests for both verification scripts │ * Move existing scritp tests into tests subdirectory │ * Add script-tests.yaml workflow to run script unit tests in CI │ * Add docs/overlays/core.md with install instructions, components │ table, and resource requirements │ * Add docs/overlays/developing.md guide covering resource limit and │ documentation requirements for overlay contributors │ * Add resource requirements frontmatter and section to metrics overlay │ docs │ * Refactor validate.yaml to discover overlays dynamically instead of │ a hardcoded list │ * Update README with new script descriptions and test instruction Signed-off-by: Thomas Cooper <code@tomcooper.dev>
1 parent ec12ce2 commit 3b23a30

14 files changed

Lines changed: 2520 additions & 20 deletions
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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} equals {@code resources.limits}
206+
* for both CPU and memory (Guaranteed QoS invariant).
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> checkRequestsEqualsLimits(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

Comments
 (0)