From 4bdc33591498fb4a674cf7de4c25c849150f2fdb Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Fri, 20 Nov 2020 17:15:38 +1100 Subject: [PATCH 1/2] Support "on-by-config" features in usage tracking The existing FeatureUsage API tracks the last time that a feature was used. This is useful for most features, but some features like security realms are on by virtue of being configured on the node, and their use is not tracked by events but simply by being turned on. This change modifies the feature tracking to support turning a feature on permanently, so that the last used time is always returned as "now". It also supports having identifiers for such "always on" features, so that callers can understand what entity in elasticsearch cause the feature to be tracked as on (e.g. the name of the security realm). --- .../license/GetFeatureUsageResponse.java | 15 ++- .../TransportGetFeatureUsageAction.java | 16 ++-- .../license/XPackLicenseState.java | 96 +++++++++++++++++-- .../license/GetFeatureUsageResponseTests.java | 94 ++++++++++++++++++ .../license/XPackLicenseStateTests.java | 46 +++++++-- 5 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/license/GetFeatureUsageResponseTests.java diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/GetFeatureUsageResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/GetFeatureUsageResponse.java index 335190d6bc4ee..5f3144dd1a2f7 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/GetFeatureUsageResponse.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/GetFeatureUsageResponse.java @@ -6,6 +6,7 @@ package org.elasticsearch.license; +import org.elasticsearch.Version; import org.elasticsearch.action.ActionResponse; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; @@ -19,6 +20,7 @@ import java.time.ZonedDateTime; import java.util.Collections; import java.util.List; +import java.util.Set; public class GetFeatureUsageResponse extends ActionResponse implements ToXContentObject { @@ -26,17 +28,24 @@ public static class FeatureUsageInfo implements Writeable { public final String name; public final ZonedDateTime lastUsedTime; public final String licenseLevel; + public final Set identifiers; - public FeatureUsageInfo(String name, ZonedDateTime lastUsedTime, String licenseLevel) { + public FeatureUsageInfo(String name, ZonedDateTime lastUsedTime, String licenseLevel, Set identifiers) { this.name = name; this.lastUsedTime = lastUsedTime; this.licenseLevel = licenseLevel; + this.identifiers = identifiers; } public FeatureUsageInfo(StreamInput in) throws IOException { this.name = in.readString(); this.lastUsedTime = ZonedDateTime.ofInstant(Instant.ofEpochSecond(in.readLong()), ZoneOffset.UTC); this.licenseLevel = in.readString(); + if (in.getVersion().onOrAfter(Version.V_8_0_0)) { + this.identifiers = in.readSet(StreamInput::readString); + } else { + this.identifiers = Set.of(); + } } @Override @@ -44,6 +53,9 @@ public void writeTo(StreamOutput out) throws IOException { out.writeString(name); out.writeLong(lastUsedTime.toEpochSecond()); out.writeString(licenseLevel); + if (out.getVersion().onOrAfter(Version.V_8_0_0)) { + out.writeStringCollection(this.identifiers); + } } } @@ -75,6 +87,7 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.field("name", feature.name); builder.field("last_used", feature.lastUsedTime.toString()); builder.field("license_level", feature.licenseLevel); + builder.field("ids", feature.identifiers); builder.endObject(); } builder.endArray(); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/TransportGetFeatureUsageAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/TransportGetFeatureUsageAction.java index 4077f1d435055..f527979e1ad23 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/TransportGetFeatureUsageAction.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/TransportGetFeatureUsageAction.java @@ -18,9 +18,10 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Locale; -import java.util.Map; +import java.util.Set; public class TransportGetFeatureUsageAction extends HandledTransportAction { @@ -39,14 +40,13 @@ public TransportGetFeatureUsageAction(TransportService transportService, ActionF @Override protected void doExecute(Task task, GetFeatureUsageRequest request, ActionListener listener) { - Map featureUsage = licenseState.getLastUsed(); + Collection featureUsage = licenseState.getFeatureUsage(); List usageInfos = new ArrayList<>(); - for (var entry : featureUsage.entrySet()) { - XPackLicenseState.Feature feature = entry.getKey(); - String name = feature.name().toLowerCase(Locale.ROOT); - ZonedDateTime lastUsedTime = Instant.ofEpochMilli(entry.getValue()).atZone(ZoneOffset.UTC); - String licenseLevel = feature.minimumOperationMode.name().toLowerCase(Locale.ROOT); - usageInfos.add(new GetFeatureUsageResponse.FeatureUsageInfo(name, lastUsedTime, licenseLevel)); + for (var usage : featureUsage) { + String name = usage.feature.name().toLowerCase(Locale.ROOT); + ZonedDateTime lastUsedTime = Instant.ofEpochMilli(usage.lastUsed).atZone(ZoneOffset.UTC); + String licenseLevel = usage.feature.minimumOperationMode.name().toLowerCase(Locale.ROOT); + usageInfos.add(new GetFeatureUsageResponse.FeatureUsageInfo(name, lastUsedTime, licenseLevel, Set.copyOf(usage.identifiers))); } listener.onResponse(new GetFeatureUsageResponse(usageInfos)); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java index 753ae5de3b44c..a0caa878f64ae 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/license/XPackLicenseState.java @@ -15,8 +15,11 @@ import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.monitoring.MonitoringField; +import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.EnumMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -109,7 +112,7 @@ public enum Feature { } } - // temporarily non tracked feeatures which need rework in how they are checked + // temporarily non tracked features which need rework in how they are checked // so they are not tracked as always used private static final Set NON_TRACKED_FEATURES = Set.of( Feature.SECURITY_IP_FILTERING, @@ -411,6 +414,7 @@ private static class Status { private final boolean isSecurityExplicitlyEnabled; private final Map lastUsed; private final LongSupplier epochMillisProvider; + private final Map> alwaysOnFeatures; // Since Status is the only field that can be updated, we do not need to synchronize access to // XPackLicenseState. However, if status is read multiple times in a method, it can change in between @@ -427,22 +431,29 @@ public XPackLicenseState(Settings settings, LongSupplier epochMillisProvider) { // care to actually keep track of Map lastUsed = new EnumMap<>(Feature.class); for (Feature feature : Feature.values()) { - if (feature.minimumOperationMode.compareTo(OperationMode.BASIC) > 0 && NON_TRACKED_FEATURES.contains(feature) == false) { + if (isTrackableFeature(feature)) { lastUsed.put(feature, new LongAccumulator(Long::max, 0)); } } this.lastUsed = lastUsed; this.epochMillisProvider = epochMillisProvider; + this.alwaysOnFeatures = new EnumMap<>(Feature.class); + } + + protected static boolean isTrackableFeature(Feature feature) { + return feature.minimumOperationMode.compareTo(OperationMode.BASIC) > 0 && NON_TRACKED_FEATURES.contains(feature) == false; } private XPackLicenseState(List listeners, boolean isSecurityEnabled, boolean isSecurityExplicitlyEnabled, - Status status, Map lastUsed, LongSupplier epochMillisProvider) { + Status status, Map lastUsed, LongSupplier epochMillisProvider, + Map> alwaysOnFeatures) { this.listeners = listeners; this.isSecurityEnabled = isSecurityEnabled; this.isSecurityExplicitlyEnabled = isSecurityExplicitlyEnabled; this.status = status; this.lastUsed = lastUsed; this.epochMillisProvider = epochMillisProvider; + this.alwaysOnFeatures = alwaysOnFeatures; } private static boolean isSecurityExplicitlyEnabled(Settings settings) { @@ -514,6 +525,20 @@ public boolean checkFeature(Feature feature) { return allowed; } + /** + * Marks a licensed feature as on by configuration. + * By default {@link #getFeatureUsage()} method returns the last time {@link #checkFeature(Feature)} was called. + * One this method is called, the specified feature will be marked as "always in use",and {@link #getFeatureUsage()} method will return + * the current time ("now"), instead of the last time the feature was checked. + */ + public synchronized boolean setFeatureActive(Feature feature, String... identifiers) { + boolean allowed = checkFeature(feature); + if (allowed && isTrackableFeature(feature)) { + alwaysOnFeatures.computeIfAbsent(feature, ignore -> new HashSet<>()).addAll(Arrays.asList(identifiers)); + } + return allowed; + } + /** * Checks whether the given feature is allowed by the current license. *

@@ -528,10 +553,18 @@ public boolean isAllowed(Feature feature) { * * Note that if a feature has not been used, it will not appear in the map. */ - public Map getLastUsed() { - return lastUsed.entrySet().stream() + public Collection getFeatureUsage() { + final Map used = lastUsed.entrySet().stream() .filter(e -> e.getValue().get() != 0) // feature was never used - .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get())); + .map(e -> new FeatureUsage(e.getKey(), e.getValue().get(), Set.of())) + .collect(Collectors.toMap(u -> u.feature, Function.identity())); + if (alwaysOnFeatures.isEmpty() == false) { + final long now = epochMillisProvider.getAsLong(); + alwaysOnFeatures.entrySet().stream() + .map(e -> new FeatureUsage(e.getKey(), now, e.getValue())) + .forEach(u -> used.put(u.feature, u)); + } + return used.values(); } public static boolean isMachineLearningAllowedForOperationMode(final OperationMode operationMode) { @@ -606,8 +639,14 @@ public static boolean isAllowedByOperationMode( * is needed for multiple interactions with the license state. */ public XPackLicenseState copyCurrentLicenseState() { - return executeAgainstStatus(status -> - new XPackLicenseState(listeners, isSecurityEnabled, isSecurityExplicitlyEnabled, status, lastUsed, epochMillisProvider)); + return executeAgainstStatus(status -> new XPackLicenseState( + listeners, + isSecurityEnabled, + isSecurityExplicitlyEnabled, + status, + lastUsed, + epochMillisProvider, + alwaysOnFeatures)); } /** @@ -629,12 +668,49 @@ public boolean isAllowedByLicense(OperationMode minimumMode, boolean needActive) /** * A convenient method to test whether a feature is by license status. - * @see #isAllowedByLicense(OperationMode, boolean) * - * @param minimumMode The minimum license to meet or exceed + * @param minimumMode The minimum license to meet or exceed + * @see #isAllowedByLicense(OperationMode, boolean) */ public boolean isAllowedByLicense(OperationMode minimumMode) { return isAllowedByLicense(minimumMode, true); } + public static class FeatureUsage { + public final Feature feature; + public final long lastUsed; + public final Set identifiers; + + public FeatureUsage(Feature feature, long lastUsed, Set identifiers) { + this.feature = feature; + this.lastUsed = lastUsed; + this.identifiers = Objects.requireNonNull(identifiers); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("FeatureUsage{"); + sb.append("feature=").append(feature); + sb.append(", lastUsed=").append(lastUsed); + sb.append(", identifiers=").append(identifiers); + sb.append('}'); + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureUsage that = (FeatureUsage) o; + return this.lastUsed == that.lastUsed && + this.feature == that.feature && + this.identifiers.equals(that.identifiers); + } + + @Override + public int hashCode() { + return Objects.hash(feature, lastUsed, identifiers); + } + } + } diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/GetFeatureUsageResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/GetFeatureUsageResponseTests.java new file mode 100644 index 0000000000000..a402e1e939b09 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/GetFeatureUsageResponseTests.java @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.license; + +import org.elasticsearch.Version; +import org.elasticsearch.common.io.stream.NamedWriteableRegistry; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.XContentTestUtils; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.iterableWithSize; + +public class GetFeatureUsageResponseTests extends ESTestCase { + + public void testSerializationCurrentVersion() throws Exception { + final GetFeatureUsageResponse response = randomResponse(); + final Version version = Version.CURRENT; + final GetFeatureUsageResponse read = copyResponse(response, version); + assertThat(read.getFeatures(), hasSize(response.getFeatures().size())); + for (int i = 0; i < response.getFeatures().size(); i++) { + final GetFeatureUsageResponse.FeatureUsageInfo origFeature = response.getFeatures().get(i); + final GetFeatureUsageResponse.FeatureUsageInfo readFeature = read.getFeatures().get(i); + assertThat(readFeature.name, equalTo(origFeature.name)); + assertThat(readFeature.licenseLevel, equalTo(origFeature.licenseLevel)); + assertThat(readFeature.lastUsedTime, equalTo(origFeature.lastUsedTime)); + assertThat(readFeature.identifiers, equalTo(origFeature.identifiers)); + } + } + + public void testSerializationVersion7_10() throws Exception { + final GetFeatureUsageResponse response = randomResponse(); + final Version version = Version.V_7_10_0; + final GetFeatureUsageResponse read = copyResponse(response, version); + assertThat(read.getFeatures(), hasSize(response.getFeatures().size())); + for (int i = 0; i < response.getFeatures().size(); i++) { + final GetFeatureUsageResponse.FeatureUsageInfo origFeature = response.getFeatures().get(i); + final GetFeatureUsageResponse.FeatureUsageInfo readFeature = read.getFeatures().get(i); + assertThat(readFeature.name, equalTo(origFeature.name)); + assertThat(readFeature.licenseLevel, equalTo(origFeature.licenseLevel)); + assertThat(readFeature.lastUsedTime, equalTo(origFeature.lastUsedTime)); + assertThat(readFeature.identifiers, emptyIterable()); + } + } + + public void testToXContent() throws Exception { + final GetFeatureUsageResponse response = randomResponse(); + var map = XContentTestUtils.convertToMap(response); + + assertThat(map.get("features"), instanceOf(List.class)); + final List features = (List) map.get("features"); + assertThat(features, iterableWithSize(response.getFeatures().size())); + + for (int i = 0; i < features.size(); i++) { + assertThat(features.get(i), instanceOf(Map.class)); + var read = (Map) features.get(i); + final GetFeatureUsageResponse.FeatureUsageInfo orig = response.getFeatures().get(i); + assertThat(read.get("name"), equalTo(orig.name)); + assertThat(read.get("license_level"), equalTo(orig.licenseLevel)); + assertThat(read.get("last_used"), equalTo(orig.lastUsedTime.toString())); + assertThat(Set.copyOf((Collection) read.get("ids")), equalTo(orig.identifiers)); + } + } + + protected GetFeatureUsageResponse randomResponse() { + final String featureName = randomAlphaOfLengthBetween(8, 24); + final Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + final ZonedDateTime lastUsed = ZonedDateTime.ofInstant(now.minusSeconds(randomIntBetween(1, 10_000)), ZoneOffset.UTC); + final String licenseLevel = randomAlphaOfLengthBetween(4, 12); + final Set identifiers = Set.copyOf(randomList(8, () -> randomAlphaOfLengthBetween(2, 8))); + final List usage = randomList(1, 5, + () -> new GetFeatureUsageResponse.FeatureUsageInfo(featureName, lastUsed, licenseLevel, identifiers)); + return new GetFeatureUsageResponse(usage); + } + + protected GetFeatureUsageResponse copyResponse(GetFeatureUsageResponse response, Version version) throws java.io.IOException { + return copyWriteable(response, new NamedWriteableRegistry(List.of()), GetFeatureUsageResponse::new, version); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/XPackLicenseStateTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/XPackLicenseStateTests.java index dd521ce4d9ba3..59014050f921d 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/license/XPackLicenseStateTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/license/XPackLicenseStateTests.java @@ -15,7 +15,11 @@ import org.elasticsearch.xpack.core.XPackSettings; import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -570,23 +574,49 @@ public void testTransformInactiveBasic() { assertAllowed(BASIC, false, s -> s.checkFeature(Feature.TRANSFORM), false); } - public void testLastUsed() { + public void testFeatureUsage() { Feature basicFeature = Feature.SECURITY; Feature goldFeature = Feature.SECURITY_DLS_FLS; + Feature goldFeature2 = Feature.WATCHER; AtomicInteger currentTime = new AtomicInteger(100); // non zero start time XPackLicenseState licenseState = new XPackLicenseState(Settings.EMPTY, currentTime::get); - assertThat("basic features not tracked", licenseState.getLastUsed(), not(hasKey(basicFeature))); - assertThat("initial epoch time", licenseState.getLastUsed(), not(hasKey(goldFeature))); + assertThat("basic features not tracked", toMap(licenseState.getFeatureUsage()), not(hasKey(basicFeature))); + assertThat("initial epoch time", toMap(licenseState.getFeatureUsage()), not(hasKey(goldFeature))); licenseState.isAllowed(basicFeature); - assertThat("basic features still not tracked", licenseState.getLastUsed(), not(hasKey(basicFeature))); + assertThat("basic features still not tracked", toMap(licenseState.getFeatureUsage()), not(hasKey(basicFeature))); licenseState.isAllowed(goldFeature); - assertThat("isAllowed does not track", licenseState.getLastUsed(), not(hasKey(goldFeature))); + assertThat("isAllowed does not track", toMap(licenseState.getFeatureUsage()), not(hasKey(goldFeature))); licenseState.checkFeature(basicFeature); - assertThat("basic features still not tracked", licenseState.getLastUsed(), not(hasKey(basicFeature))); + assertThat("basic features still not tracked", toMap(licenseState.getFeatureUsage()), not(hasKey(basicFeature))); licenseState.checkFeature(goldFeature); - assertThat("checkFeature tracks used time", licenseState.getLastUsed(), hasEntry(goldFeature, 100L)); + assertThat("checkFeature tracks used time", toMap(licenseState.getFeatureUsage()), + hasEntry(goldFeature, new XPackLicenseState.FeatureUsage(goldFeature, 100L, Set.of()))); + assertThat("checkFeature tracks used time", toMap(licenseState.getFeatureUsage()), hasKey(goldFeature)); currentTime.set(200); licenseState.checkFeature(goldFeature); - assertThat("checkFeature updates tracked time", licenseState.getLastUsed(), hasEntry(goldFeature, 200L)); + assertThat("checkFeature updates tracked time", toMap(licenseState.getFeatureUsage()), + hasEntry(goldFeature, new XPackLicenseState.FeatureUsage(goldFeature, 200L, Set.of()))); + assertThat("checkFeature updates only tracked feature", toMap(licenseState.getFeatureUsage()), not(hasKey(goldFeature2))); + + final String id1 = randomAlphaOfLength(5), id2 = randomAlphaOfLength(12); + licenseState.setFeatureActive(goldFeature2, id1, id2); + assertThat("setFeatureActive updates tracked time", toMap(licenseState.getFeatureUsage()), + hasEntry(goldFeature2, new XPackLicenseState.FeatureUsage(goldFeature2, 200L, Set.of(id1, id2)))); + + currentTime.set(300); + assertThat("setFeatureActive cause usage to always current time", toMap(licenseState.getFeatureUsage()), + hasEntry(goldFeature2, new XPackLicenseState.FeatureUsage(goldFeature2, 300L, Set.of(id1, id2)))); + assertThat("other features not affected by active feature tracking", toMap(licenseState.getFeatureUsage()), + hasEntry(goldFeature, new XPackLicenseState.FeatureUsage(goldFeature, 200L, Set.of()))); + assertThat("basic features still not tracked", toMap(licenseState.getFeatureUsage()), not(hasKey(basicFeature))); + + final String id3 = randomAlphaOfLength(3); + licenseState.setFeatureActive(goldFeature2, id3); + assertThat("setFeatureActive retains old ids", toMap(licenseState.getFeatureUsage()), + hasEntry(goldFeature2, new XPackLicenseState.FeatureUsage(goldFeature2, 300L, Set.of(id1, id2, id3)))); + } + + private Map toMap(Collection collection) { + return collection.stream().collect(Collectors.toMap(u -> u.feature, Function.identity())); } } From d2a22464f04f89eb6582db74c0565951d6f33a7d Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Mon, 23 Nov 2020 16:54:02 +1100 Subject: [PATCH 2/2] Add missing change in test/ tree --- .../java/org/elasticsearch/test/XContentTestUtils.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java b/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java index 2fb36196eee2c..c69c1be766d96 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java +++ b/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java @@ -54,9 +54,13 @@ private XContentTestUtils() { public static Map convertToMap(ToXContent part) throws IOException { XContentBuilder builder = XContentFactory.jsonBuilder(); - builder.startObject(); - part.toXContent(builder, EMPTY_PARAMS); - builder.endObject(); + if (part.isFragment()) { + builder.startObject(); + part.toXContent(builder, EMPTY_PARAMS); + builder.endObject(); + } else { + part.toXContent(builder, EMPTY_PARAMS); + } return XContentHelper.convertToMap(BytesReference.bytes(builder), false, builder.contentType()).v2(); }