Skip to content

Commit 71e3dcd

Browse files
committed
Add support for YAML configuration inheritance
1 parent dec5de3 commit 71e3dcd

2 files changed

Lines changed: 635 additions & 1 deletion

File tree

plugin/src/main/java/io/jenkins/plugins/casc/yaml/YamlUtils.java

Lines changed: 323 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,26 @@
1212
import java.io.InputStreamReader;
1313
import java.io.Reader;
1414
import java.net.URI;
15+
import java.net.URISyntaxException;
1516
import java.net.URL;
1617
import java.nio.file.Files;
1718
import java.nio.file.Path;
19+
import java.util.ArrayList;
20+
import java.util.HashMap;
21+
import java.util.HashSet;
22+
import java.util.LinkedHashMap;
1823
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Set;
1926
import java.util.logging.Logger;
2027
import org.yaml.snakeyaml.LoaderOptions;
2128
import org.yaml.snakeyaml.composer.Composer;
2229
import org.yaml.snakeyaml.error.YAMLException;
30+
import org.yaml.snakeyaml.nodes.MappingNode;
2331
import org.yaml.snakeyaml.nodes.Node;
32+
import org.yaml.snakeyaml.nodes.NodeTuple;
33+
import org.yaml.snakeyaml.nodes.ScalarNode;
34+
import org.yaml.snakeyaml.nodes.SequenceNode;
2435
import org.yaml.snakeyaml.parser.ParserImpl;
2536
import org.yaml.snakeyaml.reader.StreamReader;
2637
import org.yaml.snakeyaml.resolver.Resolver;
@@ -35,9 +46,16 @@ public final class YamlUtils {
3546
public static Node merge(List<YamlSource> sources, ConfigurationContext context) throws ConfiguratorException {
3647
Node root = null;
3748
MergeStrategy mergeStrategy = MergeStrategyFactory.getMergeStrategyOrDefault(context.getMergeStrategy());
49+
Map<String, Node> parsedCache = new HashMap<>();
50+
3851
for (YamlSource<?> source : sources) {
3952
try (Reader reader = reader(source)) {
40-
final Node node = read(source, reader, context);
53+
Node node = read(source, reader, context);
54+
if (node != null) {
55+
Set<String> visited = new HashSet<>();
56+
visited.add(getCanonicalId(source));
57+
node = resolveExtends(node, source, context, visited, parsedCache);
58+
}
4159

4260
if (root == null) {
4361
root = node;
@@ -128,4 +146,308 @@ public Node getSingleNode() {
128146
});
129147
return (Mapping) constructor.getSingleData(Mapping.class);
130148
}
149+
150+
private static Node resolveExtends(
151+
Node node,
152+
YamlSource<?> currentSource,
153+
ConfigurationContext context,
154+
Set<String> visited,
155+
Map<String, Node> parsedCache)
156+
throws ConfiguratorException {
157+
158+
if (node instanceof MappingNode mapNode) {
159+
List<NodeTuple> originalTuples = mapNode.getValue();
160+
List<NodeTuple> resolvedTuples = new ArrayList<>();
161+
162+
List<String> extendsPaths = new ArrayList<>();
163+
boolean hasChanges = false;
164+
165+
for (NodeTuple tuple : originalTuples) {
166+
Node keyNode = tuple.getKeyNode();
167+
Node valueNode = tuple.getValueNode();
168+
169+
if (keyNode instanceof ScalarNode key && "_extends".equals(key.getValue())) {
170+
if (valueNode instanceof ScalarNode scalar) {
171+
if (scalar.getValue() == null
172+
|| scalar.getValue().trim().isEmpty()) {
173+
throw new ConfiguratorException("The '_extends' property cannot be empty.");
174+
}
175+
extendsPaths.add(scalar.getValue());
176+
} else if (valueNode instanceof SequenceNode seq) {
177+
if (seq.getValue().isEmpty()) {
178+
throw new ConfiguratorException("The '_extends' list cannot be empty.");
179+
}
180+
for (Node item : seq.getValue()) {
181+
if (item instanceof ScalarNode scalarItem) {
182+
String path = scalarItem.getValue();
183+
if (path == null || path.trim().isEmpty()) {
184+
throw new ConfiguratorException(
185+
"Items in the '_extends' list cannot be null or empty strings.");
186+
}
187+
extendsPaths.add(path);
188+
} else {
189+
throw new ConfiguratorException(String.format(
190+
"Invalid item in '_extends': expected string but got %s in %s",
191+
item.getNodeId(), currentSource));
192+
}
193+
}
194+
} else {
195+
throw new ConfiguratorException(String.format(
196+
"Invalid value for '_extends' key. Expected string or list of strings, but found: %s",
197+
valueNode.getNodeId()));
198+
}
199+
hasChanges = true;
200+
continue;
201+
}
202+
203+
Node resolvedValue =
204+
resolveExtends(valueNode, currentSource, context, Set.copyOf(visited), parsedCache);
205+
206+
if (resolvedValue != valueNode) {
207+
resolvedTuples.add(new NodeTuple(keyNode, resolvedValue));
208+
hasChanges = true;
209+
} else {
210+
resolvedTuples.add(tuple);
211+
}
212+
}
213+
214+
if (!hasChanges) {
215+
return node;
216+
}
217+
218+
MappingNode newMapNode = new MappingNode(
219+
mapNode.getTag(),
220+
true,
221+
resolvedTuples,
222+
mapNode.getStartMark(),
223+
mapNode.getEndMark(),
224+
mapNode.getFlowStyle());
225+
226+
if (!extendsPaths.isEmpty()) {
227+
Node baseNode = null;
228+
229+
for (String path : extendsPaths) {
230+
YamlSource<?> parentSource = resolveRelativeSource(currentSource, path);
231+
String parentId = getCanonicalId(parentSource);
232+
233+
if (visited.contains(parentId)) {
234+
throw new ConfiguratorException("Circular _extends dependency detected: " + parentId);
235+
}
236+
237+
Node parentNode;
238+
if (parsedCache.containsKey(parentId)) {
239+
parentNode = cloneNode(parsedCache.get(parentId));
240+
} else {
241+
Set<String> newVisited = new HashSet<>(visited);
242+
newVisited.add(parentId);
243+
244+
try (Reader parentReader = reader(parentSource)) {
245+
parentNode = read(parentSource, parentReader, context);
246+
} catch (IOException e) {
247+
throw new ConfiguratorException("Failed to read extended config: " + path, e);
248+
}
249+
250+
parentNode =
251+
resolveExtends(parentNode, parentSource, context, Set.copyOf(newVisited), parsedCache);
252+
253+
parentNode = cloneNode(parentNode);
254+
parsedCache.put(parentId, parentNode);
255+
}
256+
257+
if (baseNode == null) {
258+
baseNode = parentNode;
259+
} else {
260+
baseNode = deepMergeNodes(baseNode, parentNode);
261+
}
262+
}
263+
264+
return deepMergeNodes(baseNode, newMapNode);
265+
}
266+
267+
return newMapNode;
268+
269+
} else if (node instanceof SequenceNode seqNode) {
270+
List<Node> originalChildren = seqNode.getValue();
271+
List<Node> resolvedChildren = new ArrayList<>();
272+
boolean hasChanges = false;
273+
274+
for (Node child : originalChildren) {
275+
Node resolvedChild = resolveExtends(child, currentSource, context, Set.copyOf(visited), parsedCache);
276+
resolvedChildren.add(resolvedChild);
277+
if (resolvedChild != child) {
278+
hasChanges = true;
279+
}
280+
}
281+
282+
if (hasChanges) {
283+
return new SequenceNode(
284+
seqNode.getTag(),
285+
true,
286+
resolvedChildren,
287+
seqNode.getStartMark(),
288+
seqNode.getEndMark(),
289+
seqNode.getFlowStyle());
290+
}
291+
}
292+
293+
return node;
294+
}
295+
296+
private static YamlSource<?> resolveRelativeSource(YamlSource<?> currentSource, String extendsPath)
297+
throws ConfiguratorException {
298+
if (ConfigurationAsCode.isSupportedURI(extendsPath)) {
299+
return YamlSource.of(extendsPath);
300+
}
301+
302+
Object src = currentSource.source;
303+
304+
if (src instanceof Path currentPath) {
305+
Path resolvedPath = currentPath.resolveSibling(extendsPath).normalize();
306+
307+
if (!Files.exists(resolvedPath)) {
308+
throw new ConfiguratorException("Extended configuration file does not exist: " + resolvedPath);
309+
}
310+
return YamlSource.of(resolvedPath);
311+
312+
} else if (src instanceof String) {
313+
try {
314+
URI currentUri = new URI((String) src);
315+
URI resolvedUri = currentUri.resolve(extendsPath).normalize();
316+
return YamlSource.of(resolvedUri.toString());
317+
} catch (URISyntaxException e) {
318+
throw new ConfiguratorException("Invalid base URI to resolve against: " + src, e);
319+
}
320+
321+
} else if (src instanceof HttpServletRequest || src instanceof InputStream) {
322+
throw new ConfiguratorException(
323+
"Relative `_extends` paths ('" + extendsPath + "') are not supported for inline configurations. "
324+
+ "Use an absolute file: or http(s): URL instead.");
325+
}
326+
327+
throw new ConfiguratorException("Cannot resolve relative path '" + extendsPath + "' for source type: "
328+
+ src.getClass().getSimpleName());
329+
}
330+
331+
private static String extractKey(Node keyNode) throws ConfiguratorException {
332+
if (keyNode instanceof ScalarNode scalarKey) {
333+
return scalarKey.getValue();
334+
}
335+
throw new ConfiguratorException(String.format(
336+
"Invalid YAML key type: %s. JCasC only supports scalar (string) keys.", keyNode.getNodeId()));
337+
}
338+
339+
private static Node deepMergeNodes(Node base, Node override) throws ConfiguratorException {
340+
if (base != null && override != null) {
341+
boolean isBaseMap = base instanceof MappingNode;
342+
boolean isBaseSeq = base instanceof SequenceNode;
343+
boolean isOverrideMap = override instanceof MappingNode;
344+
boolean isOverrideSeq = override instanceof SequenceNode;
345+
346+
if ((isBaseMap && isOverrideSeq) || (isBaseSeq && isOverrideMap)) {
347+
throw new ConfiguratorException(String.format(
348+
"Type mismatch during merge: Cannot merge a %s and a %s. "
349+
+ "Check your '_extends' hierarchy for incompatible data structures.",
350+
override.getNodeId(), base.getNodeId()));
351+
}
352+
}
353+
if (base instanceof MappingNode baseMap && override instanceof MappingNode overrideMap) {
354+
Map<String, NodeTuple> mergedTuples = new LinkedHashMap<>();
355+
356+
for (NodeTuple bTuple : baseMap.getValue()) {
357+
String key = extractKey(bTuple.getKeyNode());
358+
mergedTuples.put(key, bTuple);
359+
}
360+
361+
for (NodeTuple oTuple : overrideMap.getValue()) {
362+
String key = extractKey(oTuple.getKeyNode());
363+
364+
if (mergedTuples.containsKey(key)) {
365+
Node bValue = mergedTuples.get(key).getValueNode();
366+
Node oValue = oTuple.getValueNode();
367+
368+
if (bValue instanceof MappingNode && oValue instanceof MappingNode) {
369+
Node mergedValue = deepMergeNodes(bValue, oValue);
370+
mergedTuples.put(key, new NodeTuple(oTuple.getKeyNode(), mergedValue));
371+
} else {
372+
mergedTuples.put(key, new NodeTuple(oTuple.getKeyNode(), cloneNode(oValue)));
373+
}
374+
} else {
375+
mergedTuples.put(key, new NodeTuple(oTuple.getKeyNode(), cloneNode(oTuple.getValueNode())));
376+
}
377+
}
378+
379+
return new MappingNode(
380+
overrideMap.getTag(),
381+
true,
382+
new ArrayList<>(mergedTuples.values()),
383+
baseMap.getStartMark(),
384+
overrideMap.getEndMark(),
385+
overrideMap.getFlowStyle());
386+
}
387+
return cloneNode(override);
388+
}
389+
390+
private static Node cloneNode(Node node) {
391+
if (node == null) {
392+
return null;
393+
}
394+
395+
if (node instanceof MappingNode mapNode) {
396+
List<NodeTuple> clonedTuples = new ArrayList<>();
397+
for (NodeTuple tuple : mapNode.getValue()) {
398+
clonedTuples.add(new NodeTuple(cloneNode(tuple.getKeyNode()), cloneNode(tuple.getValueNode())));
399+
}
400+
return new MappingNode(
401+
mapNode.getTag(),
402+
true,
403+
clonedTuples,
404+
mapNode.getStartMark(),
405+
mapNode.getEndMark(),
406+
mapNode.getFlowStyle());
407+
408+
} else if (node instanceof SequenceNode seqNode) {
409+
List<Node> clonedChildren = new ArrayList<>();
410+
for (Node child : seqNode.getValue()) {
411+
clonedChildren.add(cloneNode(child));
412+
}
413+
return new SequenceNode(
414+
seqNode.getTag(),
415+
true,
416+
clonedChildren,
417+
seqNode.getStartMark(),
418+
seqNode.getEndMark(),
419+
seqNode.getFlowStyle());
420+
}
421+
422+
if (node instanceof ScalarNode scalarNode) {
423+
return new ScalarNode(
424+
scalarNode.getTag(),
425+
scalarNode.getValue(),
426+
scalarNode.getStartMark(),
427+
scalarNode.getEndMark(),
428+
scalarNode.getScalarStyle());
429+
}
430+
431+
return node;
432+
}
433+
434+
private static String getCanonicalId(YamlSource<?> source) {
435+
Object src = source.source;
436+
437+
if (src instanceof Path path) {
438+
try {
439+
return path.toRealPath().toString();
440+
} catch (IOException e) {
441+
return path.toAbsolutePath().normalize().toString();
442+
}
443+
} else if (src instanceof String url) {
444+
try {
445+
return new URI(url).normalize().toString();
446+
} catch (URISyntaxException e) {
447+
return url;
448+
}
449+
}
450+
451+
return source.toString();
452+
}
131453
}

0 commit comments

Comments
 (0)