Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ public static JsonObject validate(ToolContext ctx, Path dir, String file, String
result.put("file", file);
result.put("errors", new JsonArray(errors));
putKameletGuide(result, file, errors);
putKameletNotes(result, file, content);
// the problems whose fix is certain, as edits an agent can apply (camel_edit_file find/replace)
JsonArray fixes = new JsonArray();
String[] lines = content.split("\n", -1);
Expand Down Expand Up @@ -923,6 +924,20 @@ private static String withoutLine(String message) {
return message != null ? message.replaceFirst("^Line \\d+: ", "") : "";
}

/**
* What a Kamelet file does that works but is not right, which does not refuse a write: a camel: dependency its
* template does not use (CAMEL-25403).
*/
private static void putKameletNotes(JsonObject result, String file, String content) {
String name = file != null ? file.toLowerCase(Locale.ROOT) : "";
if (name.endsWith(".kamelet.yaml") || name.endsWith(".kamelet.yml")) {
List<String> notes = KameletChecks.unusedDependencies(content);
if (!notes.isEmpty()) {
result.put("notes", new JsonArray(notes));
}
}
}

/** Writes a file after validating it, as {@code camel_write_file} does; no confirmation is asked here. */
public static JsonObject writeFile(ToolContext ctx, Path dir, String file, String content, boolean validate) {
Path path = resolveFile(dir, file);
Expand Down Expand Up @@ -981,6 +996,9 @@ public static JsonObject writeFile(ToolContext ctx, Path dir, String file, Strin
// written with problems the file already had: said, so they are not taken for fixed
result.put("existingProblems", new JsonArray(problemsBefore));
}
if (validate) {
putKameletNotes(result, file, content);
}
if (watch) {
JsonObject reload = ReloadOutcome.await(ctx.pid(), processName, sinceKey, RELOAD_WAIT_MILLIS);
result.put("reload", reload);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
Expand Down Expand Up @@ -371,6 +372,161 @@ && scalar(value(fm0, "uri")).startsWith("kamelet:")
return errors;
}

/** The scheme of an endpoint uri written as a value: timer:orders, kamelet:source, https://... */
private static final Pattern URI_SCHEME = Pattern.compile("^([a-zA-Z][a-zA-Z0-9+.-]*):");

/**
* The camel: dependencies of a Kamelet file that name a component its template does not use: no endpoint of the
* template has its scheme, and no language or data format of the template is in its artifact. Notes, not errors:
* the Kamelet works, but an exported project gets the artifact for nothing. A model copies them from a sample whose
* template starts from a timer, or from the route that uses the Kamelet (CAMEL-25403). Nothing is said when the
* template could use a component in a way not visible here: a bean, a class reference or a placeholder as a scheme.
* camel:core is noted as implied, as every Camel runtime has it.
*/
public static List<String> unusedDependencies(String content) {
List<String> notes = new ArrayList<>();
Node root;
try {
root = new Yaml(new SafeConstructor(new LoaderOptions())).compose(new StringReader(content));
} catch (Exception e) {
return notes;
}
if (!(root instanceof MappingNode doc) || !"Kamelet".equals(scalar(value(doc, "kind")))
|| !(value(doc, "spec") instanceof MappingNode spec)
|| !(value(spec, "dependencies") instanceof SequenceNode deps)
|| !(value(spec, "template") instanceof MappingNode template)) {
return notes;
}
Set<String> used = new HashSet<>();
// false when the template may use a component unseen: then only camel:core is noted
boolean inspectable = collectUsed(null, template, used);
CamelCatalog catalog = catalog();
Set<String> usedArtifacts = new HashSet<>();
for (String name : used) {
String artifact = artifactOf(catalog, name);
if (artifact == null) {
// https: is a scheme of the http component, not a component of its own
artifact = alternativeSchemes(catalog).get(name);
}
if (artifact != null) {
usedArtifacts.add(artifact);
}
}
for (Node d : deps.getValue()) {
String dep = scalar(d);
if (dep == null || !dep.startsWith("camel:")) {
continue;
}
String name = dep.substring("camel:".length());
if ("core".equals(name)) {
// every Camel runtime has camel-core; camel:kamelet is not implied, it runs the template
notes.add(EndpointChecks.linePrefix(line(d)) + "spec.dependencies: camel:core is implied, every Camel"
+ " runtime has it: leave it out");
continue;
}
if (!inspectable) {
continue;
}
ComponentModel model = catalog.componentModel(name);
// only a component is checked: a language or a data format may be used in ways the template does not show
if (model == null || catalog.languageModel(name) != null || catalog.dataFormatModel(name) != null
|| usedArtifacts.contains(model.getArtifactId()) || delegatedTo(used, name)) {
continue;
}
notes.add(EndpointChecks.linePrefix(line(d)) + "spec.dependencies: " + dep + " is not used by the template"
+ " (it has no " + name + ": endpoint): list only the components, languages and data formats the"
+ " template uses");
}
return notes;
}

/** Components that run on another one the template does not name: cron on quartz, rest-openapi on an http one. */
private static final Map<String, Pattern> DELEGATES = Map.of(
"cron", Pattern.compile("quartz|spring.*"),
"rest", Pattern.compile(".*http.*|undertow|jetty"),
"rest-openapi", Pattern.compile(".*http.*|undertow|jetty"));

private static boolean delegatedTo(Set<String> used, String component) {
for (Map.Entry<String, Pattern> e : DELEGATES.entrySet()) {
if (used.contains(e.getKey()) && e.getValue().matcher(component).matches()) {
return true;
}
}
return false;
}

/**
* Adds the keys and the uri schemes of a template to the set; false when the template may use a component in a way
* the keys and schemes do not show.
*/
private static boolean collectUsed(String key, Node node, Set<String> used) {
if (node instanceof MappingNode map) {
for (NodeTuple t : map.getValue()) {
String name = scalar(t.getKeyNode());
if ("beans".equals(name) || "bean".equals(name) || "method".equals(name)) {
// a class of the Kamelet's own may need the artifact (the Kafka transform actions need camel:kafka)
return false;
}
if (name != null) {
used.add(name);
}
if (!collectUsed(name, t.getValueNode(), used)) {
return false;
}
}
} else if (node instanceof SequenceNode seq) {
for (Node n : seq.getValue()) {
if (!collectUsed(key, n, used)) {
return false;
}
}
} else if (node instanceof ScalarNode s) {
String v = s.getValue().trim();
if (v.contains("#class:") || v.contains("#type:")
|| v.startsWith("{{") && ("uri".equals(key) || ENDPOINT_KEYS.contains(key))) {
// a bean of a class, or an endpoint whose scheme is a property
return false;
}
Matcher m = URI_SCHEME.matcher(v);
if (m.find()) {
used.add(m.group(1));
} else if ("uri".equals(key) || ENDPOINT_KEYS.contains(key)) {
// uri: aws2-sqs with the options under parameters:, as the canonical form writes it
used.add(v.contains("?") ? v.substring(0, v.indexOf('?')) : v);
}
}
return true;
}

private static volatile Map<String, String> alternativeSchemes;

/** The alternative schemes of the components (https of http), each with the artifact of its component. */
private static Map<String, String> alternativeSchemes(CamelCatalog catalog) {
Map<String, String> answer = alternativeSchemes;
if (answer == null) {
answer = new HashMap<>();
for (String n : catalog.findComponentNames()) {
ComponentModel model = catalog.componentModel(n);
if (model != null && model.getAlternativeSchemes() != null) {
for (String scheme : model.getAlternativeSchemes().split(",")) {
answer.putIfAbsent(scheme.trim(), model.getArtifactId());
}
}
}
alternativeSchemes = answer;
}
return answer;
}

private static CamelCatalog catalog() {
CamelCatalog catalog = dependencyCatalog;
if (catalog == null) {
catalog = new DefaultCamelCatalog();
dependencyCatalog = catalog;
}
return catalog;
}

private static volatile CamelCatalog dependencyCatalog;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,118 @@ void theProjectsOwnKamelet(@TempDir Path dir) throws Exception {
expression: "%s"
""";

// CAMEL-25403: a camel: dependency the template does not use, as the model copied camel:timer from the source sample

private static String withDependencies(String kamelet, String... deps) {
StringBuilder sb = new StringBuilder(" dependencies:\n");
for (String d : deps) {
sb.append(" - \"").append(d).append("\"\n");
}
return kamelet.replace(" template:\n", sb + " template:\n");
}

@Test
void aDependencyTheTemplateDoesNotUseIsANote() {
String content = withDependencies(TAG_KAMELET.formatted("${body} [{{tag}}]"), "camel:timer");
assertThat(KameletChecks.unusedDependencies(content)).singleElement().asString()
.contains("camel:timer is not used by the template")
.contains("no timer: endpoint");
// a note, not an error: the Kamelet works, so the write is not refused
assertThat(KameletChecks.validateKameletFile(content)).isEmpty();
}

@Test
void camelCoreIsImpliedAndCamelKameletIsNot() {
String content = withDependencies(TAG_KAMELET.formatted("${body} [{{tag}}]"), "camel:core", "camel:kamelet");
assertThat(KameletChecks.unusedDependencies(content)).singleElement().asString()
.contains("camel:core is implied");
// also when the template uses a bean, whose class may need the other dependencies
String bean = withDependencies(TAG_KAMELET.formatted("${body}"), "camel:core", "camel:kafka")
.replace(" - setBody:\n",
" - bean:\n beanType: org.example.HoistField\n - setBody:\n");
assertThat(KameletChecks.unusedDependencies(bean)).singleElement().asString()
.contains("camel:core is implied");
}

@Test
void theDependenciesTheTemplateUsesAreNotNoted() {
String source = """
apiVersion: camel.apache.org/v1
kind: Kamelet
metadata:
name: order-source
labels:
camel.apache.org/kamelet.type: source
spec:
definition:
title: Order Source
properties:
period:
title: Period
type: integer
default: 5000
dependencies:
- "camel:timer"
- "camel:jq"
- "camel:http"
- "camel:kamelet"
- "mvn:org.example:orders:1.0"
template:
from:
uri: timer:orders
parameters:
period: "{{period}}"
steps:
- setBody:
expression:
jq:
expression: '.'
- to:
uri: https://example.com/orders
- to:
uri: kamelet:sink
""";
assertThat(KameletChecks.unusedDependencies(source)).isEmpty();
}

@Test
void aTemplateThatMayUseAComponentUnseenIsNotNoted() {
String placeholderScheme = withDependencies(TAG_KAMELET.formatted("${body}"), "camel:kafka")
.replace("uri: kamelet:source", "uri: \"{{scheme}}:orders\"");
assertThat(KameletChecks.unusedDependencies(placeholderScheme)).isEmpty();
String bean = withDependencies(TAG_KAMELET.formatted("${body}"), "camel:kafka")
.replace(" from:\n",
" beans:\n - name: client\n type: \"#class:org.example.Client\"\n from:\n");
assertThat(KameletChecks.unusedDependencies(bean)).isEmpty();
// the Kafka transform actions of the catalog call a class that needs camel:kafka
String beanStep = withDependencies(TAG_KAMELET.formatted("${body}"), "camel:kafka")
.replace(" - setBody:\n",
" - bean:\n beanType: org.example.HoistField\n - setBody:\n");
assertThat(KameletChecks.unusedDependencies(beanStep)).isEmpty();
}

@Test
void aComponentThatRunsOnAnotherIsNotNoted() {
// cron runs on quartz, rest-openapi sends with an http component: neither is named by the template
String cron = withDependencies(TAG_KAMELET.formatted("${body}"), "camel:cron", "camel:quartz")
.replace("uri: kamelet:source", "uri: \"cron:tick?schedule=0/3+*+*+*+*+?\"");
assertThat(KameletChecks.unusedDependencies(cron)).isEmpty();
String rest = withDependencies(TAG_KAMELET.formatted("${body}"), "camel:rest-openapi", "camel:http")
.replace(" - setBody:\n", " - to:\n uri: rest-openapi\n - setBody:\n");
assertThat(KameletChecks.unusedDependencies(rest)).isEmpty();
}

@Test
void writingAKameletReportsTheNotes(@TempDir Path dir) {
String content = withDependencies(TAG_KAMELET.formatted("${body} [{{tag}}]"), "camel:timer");
JsonObject result = AuthoringTools.writeFile(new ToolContext(), dir, "tag-order-action.kamelet.yaml", content, true);
assertThat(result.getString("status")).isEqualTo("created");
assertThat(result.toJson()).contains("camel:timer is not used by the template");
JsonObject clean = AuthoringTools.writeFile(new ToolContext(), dir, "tag-order-action.kamelet.yaml",
TAG_KAMELET.formatted("${body} [{{tag}}]"), true);
assertThat(clean.containsKey("notes")).isFalse();
}

@Test
void aRightKameletFileIsValid(@TempDir Path dir) {
String content = TAG_KAMELET.formatted("${body} [{{tag}}]");
Expand Down
Loading