Skip to content

Commit fd56174

Browse files
craigtavernerywangd
authored andcommitted
Output function signature license requirements to Kibana definitions (elastic#127717)
Output function signature license requirements to Kibana definition files, and also test that this matches the actual licensing behaviour of the functions. ES|QL functions that enforce license checks do so with the `LicenseAware` interface. This does not expose what that functions license level is, but only whether the current active license will be sufficient for that function and its current signature (data types passed in as fields). Rather than add to this interface, we've made the license level information test-only information. This means if a function implements LicenseAware, it also needs to add a method to its test class to specify the license level for the signature being called. All functions will be tested for compliance, so failing to add this will result in test failure. Also if the test license level does not match the enforced license, that will also cause a failure.
1 parent 603843c commit fd56174

File tree

15 files changed

+227
-29
lines changed

15 files changed

+227
-29
lines changed

docs/reference/query-languages/esql/kibana/definition/functions/categorize.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/reference/query-languages/esql/kibana/definition/functions/st_extent_agg.json

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/type/DataType.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,10 @@ public static boolean isSpatialPoint(DataType t) {
560560
return t == GEO_POINT || t == CARTESIAN_POINT;
561561
}
562562

563+
public static boolean isSpatialShape(DataType t) {
564+
return t == GEO_SHAPE || t == CARTESIAN_SHAPE;
565+
}
566+
563567
public static boolean isSpatialGeo(DataType t) {
564568
return t == GEO_POINT || t == GEO_SHAPE;
565569
}

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@
2626
import org.elasticsearch.compute.test.BlockTestUtils;
2727
import org.elasticsearch.compute.test.TestBlockFactory;
2828
import org.elasticsearch.indices.CrankyCircuitBreakerService;
29+
import org.elasticsearch.license.License;
30+
import org.elasticsearch.license.XPackLicenseState;
31+
import org.elasticsearch.license.internal.XPackLicenseStatus;
2932
import org.elasticsearch.logging.LogManager;
3033
import org.elasticsearch.logging.Logger;
3134
import org.elasticsearch.test.ESTestCase;
35+
import org.elasticsearch.xpack.esql.LicenseAware;
3236
import org.elasticsearch.xpack.esql.core.expression.Attribute;
3337
import org.elasticsearch.xpack.esql.core.expression.Expression;
3438
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
@@ -54,7 +58,6 @@
5458
import org.junit.After;
5559
import org.junit.AfterClass;
5660

57-
import java.io.IOException;
5861
import java.lang.reflect.Constructor;
5962
import java.lang.reflect.InvocationTargetException;
6063
import java.lang.reflect.Method;
@@ -730,7 +733,8 @@ public void testSerializationOfSimple() {
730733
*/
731734
@AfterClass
732735
public static void testFunctionInfo() {
733-
Logger log = LogManager.getLogger(getTestClass());
736+
Class<?> testClass = getTestClass();
737+
Logger log = LogManager.getLogger(testClass);
734738
FunctionDefinition definition = definition(functionName());
735739
if (definition == null) {
736740
log.info("Skipping function info checks because the function isn't registered");
@@ -753,7 +757,7 @@ public static void testFunctionInfo() {
753757
for (int i = 0; i < args.size(); i++) {
754758
typesFromSignature.add(new HashSet<>());
755759
}
756-
for (Map.Entry<List<DataType>, DataType> entry : signatures(getTestClass()).entrySet()) {
760+
for (Map.Entry<List<DataType>, DataType> entry : signatures(testClass).entrySet()) {
757761
List<DataType> types = entry.getKey();
758762
for (int i = 0; i < args.size() && i < types.size(); i++) {
759763
typesFromSignature.get(i).add(types.get(i).esNameIfPossible());
@@ -796,6 +800,101 @@ public static void testFunctionInfo() {
796800
assertEquals(returnFromSignature, returnTypes);
797801
}
798802

803+
/**
804+
* This test is meant to validate that the license checks documented match those enforced.
805+
* The expectations are set in the test class using a method with this signature:
806+
* <code>
807+
* public static License.OperationMode licenseRequirement(List&lt;DataType&gt; fieldTypes);
808+
* </code>
809+
* License enforcement in the function class is achieved using the interface <code>LicenseAware</code>.
810+
* This test will make sure the two are in agreement, and does not require that the function class actually
811+
* report its license level. If we add license checks to any function, but fail to also add the expected
812+
* license level to the test class, this test will fail.
813+
*/
814+
@AfterClass
815+
public static void testFunctionLicenseChecks() throws Exception {
816+
Class<?> testClass = getTestClass();
817+
Logger log = LogManager.getLogger(testClass);
818+
FunctionDefinition definition = definition(functionName());
819+
if (definition == null) {
820+
log.info("Skipping function info checks because the function isn't registered");
821+
return;
822+
}
823+
log.info("Running function license checks");
824+
DocsV3Support.LicenseRequirementChecker licenseChecker = new DocsV3Support.LicenseRequirementChecker(testClass);
825+
License.OperationMode functionLicense = licenseChecker.invoke(null);
826+
Constructor<?> ctor = constructorWithFunctionInfo(definition.clazz());
827+
if (LicenseAware.class.isAssignableFrom(definition.clazz()) == false) {
828+
// Perform simpler no-signature tests
829+
assertThat(
830+
"Function " + definition.name() + " should be licensed under " + functionLicense,
831+
functionLicense,
832+
equalTo(License.OperationMode.BASIC)
833+
);
834+
return;
835+
}
836+
// For classes with LicenseAware, we need to check that the license is correct
837+
TestCheckLicense checkLicense = new TestCheckLicense();
838+
839+
// Go through all signatures and assert that the license is as expected
840+
signatures(testClass).forEach((signature, returnType) -> {
841+
try {
842+
License.OperationMode license = licenseChecker.invoke(signature);
843+
assertNotNull("License should not be null", license);
844+
845+
// Construct an instance of the class and then call it's licenseCheck method, and compare the results
846+
Object[] args = new Object[signature.size() + 1];
847+
args[0] = Source.EMPTY;
848+
for (int i = 0; i < signature.size(); i++) {
849+
args[i + 1] = new Literal(Source.EMPTY, null, signature.get(i));
850+
}
851+
Object instance = ctor.newInstance(args);
852+
// Check that object implements the LicenseAware interface
853+
if (LicenseAware.class.isAssignableFrom(instance.getClass())) {
854+
LicenseAware licenseAware = (LicenseAware) instance;
855+
switch (license) {
856+
case BASIC -> checkLicense.assertLicenseCheck(licenseAware, signature, true, true, true);
857+
case PLATINUM -> checkLicense.assertLicenseCheck(licenseAware, signature, false, true, true);
858+
case ENTERPRISE -> checkLicense.assertLicenseCheck(licenseAware, signature, false, false, true);
859+
}
860+
} else {
861+
fail("Function " + definition.name() + " does not implement LicenseAware");
862+
}
863+
} catch (Exception e) {
864+
fail(e);
865+
}
866+
});
867+
}
868+
869+
private static class TestCheckLicense {
870+
XPackLicenseState basicLicense = makeLicenseState(License.OperationMode.BASIC);
871+
XPackLicenseState platinumLicense = makeLicenseState(License.OperationMode.PLATINUM);
872+
XPackLicenseState enterpriseLicense = makeLicenseState(License.OperationMode.ENTERPRISE);
873+
874+
private void assertLicenseCheck(
875+
LicenseAware licenseAware,
876+
List<DataType> signature,
877+
boolean allowsBasic,
878+
boolean allowsPlatinum,
879+
boolean allowsEnterprise
880+
) {
881+
boolean basic = licenseAware.licenseCheck(basicLicense);
882+
boolean platinum = licenseAware.licenseCheck(platinumLicense);
883+
boolean enterprise = licenseAware.licenseCheck(enterpriseLicense);
884+
assertThat("Basic license should be accepted for " + signature, basic, equalTo(allowsBasic));
885+
assertThat("Platinum license should be accepted for " + signature, platinum, equalTo(allowsPlatinum));
886+
assertThat("Enterprise license should be accepted for " + signature, enterprise, equalTo(allowsEnterprise));
887+
}
888+
889+
private void assertLicenseCheck(List<DataType> signature, boolean allowed, boolean expected) {
890+
assertThat("Basic license should " + (expected ? "" : "not ") + "be accepted for " + signature, allowed, equalTo(expected));
891+
}
892+
}
893+
894+
private static XPackLicenseState makeLicenseState(License.OperationMode mode) {
895+
return new XPackLicenseState(System::currentTimeMillis, new XPackLicenseStatus(mode, true, ""));
896+
}
897+
799898
/**
800899
* Asserts the result of a test case matches the expected result and warnings.
801900
* <p>
@@ -865,7 +964,7 @@ public static Map<List<DataType>, DataType> signatures(Class<?> testClass) {
865964
}
866965

867966
@AfterClass
868-
public static void renderDocs() throws IOException {
967+
public static void renderDocs() throws Exception {
869968
if (System.getProperty("generateDocs") == null) {
870969
return;
871970
}

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3Support.java

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import org.elasticsearch.common.Strings;
1313
import org.elasticsearch.core.PathUtils;
14+
import org.elasticsearch.license.License;
1415
import org.elasticsearch.logging.LogManager;
1516
import org.elasticsearch.logging.Logger;
1617
import org.elasticsearch.xcontent.XContentBuilder;
@@ -46,6 +47,7 @@
4647
import java.io.InputStreamReader;
4748
import java.lang.annotation.Annotation;
4849
import java.lang.reflect.Constructor;
50+
import java.lang.reflect.Method;
4951
import java.nio.charset.StandardCharsets;
5052
import java.nio.file.Files;
5153
import java.nio.file.Path;
@@ -107,7 +109,7 @@ static OperatorsDocsSupport forOperators(String name, Class<?> testClass) {
107109
return new OperatorsDocsSupport(name, testClass);
108110
}
109111

110-
static void renderDocs(String name, Class<?> testClass) throws IOException {
112+
static void renderDocs(String name, Class<?> testClass) throws Exception {
111113
if (OPERATORS.containsKey(name)) {
112114
var docs = DocsV3Support.forOperators(name, testClass);
113115
docs.renderSignature();
@@ -126,7 +128,7 @@ public static void renderNegatedOperator(
126128
String name,
127129
Function<String, String> description,
128130
Class<?> testClass
129-
) throws IOException {
131+
) throws Exception {
130132
var docs = forOperators("not " + name.toLowerCase(Locale.ROOT), testClass);
131133
docs.renderDocsForNegatedOperators(ctor, description);
132134
}
@@ -272,12 +274,46 @@ public void writeToTempDir(Path dir, String extension, String str) throws IOExce
272274
}
273275
}
274276

277+
/**
278+
* This class is used to check if a license requirement method exists in the test class.
279+
* This is used to add license requirement information to the generated documentation.
280+
*/
281+
public static class LicenseRequirementChecker {
282+
private Method staticMethod;
283+
private Function<List<DataType>, License.OperationMode> fallbackLambda;
284+
285+
public LicenseRequirementChecker(Class<?> testClass) {
286+
try {
287+
staticMethod = testClass.getMethod("licenseRequirement", List.class);
288+
if (License.OperationMode.class.equals(staticMethod.getReturnType()) == false
289+
|| java.lang.reflect.Modifier.isStatic(staticMethod.getModifiers()) == false) {
290+
staticMethod = null; // Reset if the method doesn't match the signature
291+
}
292+
} catch (NoSuchMethodException e) {
293+
staticMethod = null;
294+
}
295+
296+
if (staticMethod == null) {
297+
fallbackLambda = fieldTypes -> License.OperationMode.BASIC;
298+
}
299+
}
300+
301+
public License.OperationMode invoke(List<DataType> fieldTypes) throws Exception {
302+
if (staticMethod != null) {
303+
return (License.OperationMode) staticMethod.invoke(null, fieldTypes);
304+
} else {
305+
return fallbackLambda.apply(fieldTypes);
306+
}
307+
}
308+
}
309+
275310
protected final String category;
276311
protected final String name;
277312
protected final FunctionDefinition definition;
278313
protected final Logger logger;
279314
private final Supplier<Map<List<DataType>, DataType>> signatures;
280315
private TempFileWriter tempFileWriter;
316+
private final LicenseRequirementChecker licenseChecker;
281317

282318
protected DocsV3Support(String category, String name, Class<?> testClass, Supplier<Map<List<DataType>, DataType>> signatures) {
283319
this(category, name, null, testClass, signatures);
@@ -296,6 +332,7 @@ private DocsV3Support(
296332
this.logger = LogManager.getLogger(testClass);
297333
this.signatures = signatures;
298334
this.tempFileWriter = new DocsFileWriter();
335+
this.licenseChecker = new LicenseRequirementChecker(testClass);
299336
}
300337

301338
/** Used in tests to capture output for asserting on the content */
@@ -460,7 +497,7 @@ void writeToTempKibanaDir(String subdir, String extension, String str) throws IO
460497

461498
protected abstract void renderSignature() throws IOException;
462499

463-
protected abstract void renderDocs() throws IOException;
500+
protected abstract void renderDocs() throws Exception;
464501

465502
static class FunctionDocsSupport extends DocsV3Support {
466503
private FunctionDocsSupport(String name, Class<?> testClass) {
@@ -488,7 +525,7 @@ protected void renderSignature() throws IOException {
488525
}
489526

490527
@Override
491-
protected void renderDocs() throws IOException {
528+
protected void renderDocs() throws Exception {
492529
if (definition == null) {
493530
logger.info("Skipping rendering docs because the function '{}' isn't registered", name);
494531
} else {
@@ -497,7 +534,7 @@ protected void renderDocs() throws IOException {
497534
}
498535
}
499536

500-
private void renderDocs(FunctionDefinition definition) throws IOException {
537+
private void renderDocs(FunctionDefinition definition) throws Exception {
501538
EsqlFunctionRegistry.FunctionDescription description = EsqlFunctionRegistry.description(definition);
502539
if (name.equals("case")) {
503540
/*
@@ -711,7 +748,7 @@ public void renderSignature() throws IOException {
711748
}
712749

713750
@Override
714-
public void renderDocs() throws IOException {
751+
public void renderDocs() throws Exception {
715752
Constructor<?> ctor = constructorWithFunctionInfo(op.clazz());
716753
if (ctor != null) {
717754
FunctionInfo functionInfo = ctor.getAnnotation(FunctionInfo.class);
@@ -722,7 +759,7 @@ public void renderDocs() throws IOException {
722759
}
723760
}
724761

725-
void renderDocsForNegatedOperators(Constructor<?> ctor, Function<String, String> description) throws IOException {
762+
void renderDocsForNegatedOperators(Constructor<?> ctor, Function<String, String> description) throws Exception {
726763
String baseName = name.toLowerCase(Locale.ROOT).replace("not ", "");
727764
OperatorConfig op = OPERATORS.get(baseName);
728765
assert op != null;
@@ -795,7 +832,7 @@ public Example[] examples() {
795832
}
796833

797834
void renderDocsForOperators(String name, String titleName, Constructor<?> ctor, FunctionInfo info, boolean variadic)
798-
throws IOException {
835+
throws Exception {
799836
renderKibanaInlineDocs(name, titleName, info);
800837

801838
var params = ctor.getParameters();
@@ -999,7 +1036,7 @@ void renderKibanaFunctionDefinition(
9991036
FunctionInfo info,
10001037
List<EsqlFunctionRegistry.ArgSignature> args,
10011038
boolean variadic
1002-
) throws IOException {
1039+
) throws Exception {
10031040

10041041
try (XContentBuilder builder = JsonXContent.contentBuilder().prettyPrint().lfAtEnd().startObject()) {
10051042
builder.field(
@@ -1019,6 +1056,10 @@ void renderKibanaFunctionDefinition(
10191056
});
10201057
}
10211058
builder.field("name", name);
1059+
License.OperationMode license = licenseChecker.invoke(null);
1060+
if (license != null && license != License.OperationMode.BASIC) {
1061+
builder.field("license", license.toString());
1062+
}
10221063
if (titleName != null && titleName.equals(name) == false) {
10231064
builder.field("titleName", titleName);
10241065
}
@@ -1073,6 +1114,10 @@ void renderKibanaFunctionDefinition(
10731114
builder.endObject();
10741115
}
10751116
builder.endArray();
1117+
license = licenseChecker.invoke(sig.getKey());
1118+
if (license != null && license != License.OperationMode.BASIC) {
1119+
builder.field("license", license.toString());
1120+
}
10761121
builder.field("variadic", variadic);
10771122
builder.field("returnType", sig.getValue().esNameIfPossible());
10781123
builder.endObject();

0 commit comments

Comments
 (0)