Browse Source

Add gradle tasks and code to modify and access mappings between version ids and release versions (#103627)

This is to enable error and log messages to convert from the internal version ids to release versions, on builds where we have release versions. The release tags are updated as part of the release automation, extracting the current highest versions for all relevant version numbers and tagging them with a specific release version for conversion when needed.
Simon Cooper 1 year ago
parent
commit
a11812ebbb

+ 35 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/AbstractVersionsTask.java

@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.initialization.layout.BuildLayout;
+
+import java.nio.file.Path;
+
+public abstract class AbstractVersionsTask extends DefaultTask {
+
+    static final String TRANSPORT_VERSION_TYPE = "TransportVersion";
+    static final String INDEX_VERSION_TYPE = "IndexVersion";
+
+    static final String SERVER_MODULE_PATH = "server/src/main/java/";
+    static final String TRANSPORT_VERSION_FILE_PATH = SERVER_MODULE_PATH + "org/elasticsearch/TransportVersions.java";
+    static final String INDEX_VERSION_FILE_PATH = SERVER_MODULE_PATH + "org/elasticsearch/index/IndexVersions.java";
+
+    static final String SERVER_RESOURCES_PATH = "server/src/main/resources/";
+    static final String TRANSPORT_VERSIONS_RECORD = SERVER_RESOURCES_PATH + "org/elasticsearch/TransportVersions.csv";
+    static final String INDEX_VERSIONS_RECORD = SERVER_RESOURCES_PATH + "org/elasticsearch/index/IndexVersions.csv";
+
+    final Path rootDir;
+
+    protected AbstractVersionsTask(BuildLayout layout) {
+        rootDir = layout.getRootDirectory().toPath();
+    }
+
+}

+ 105 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ExtractCurrentVersionsTask.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import com.github.javaparser.StaticJavaParser;
+import com.github.javaparser.ast.CompilationUnit;
+import com.github.javaparser.ast.body.FieldDeclaration;
+import com.github.javaparser.ast.expr.IntegerLiteralExpr;
+
+import org.gradle.api.logging.Logger;
+import org.gradle.api.logging.Logging;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.api.tasks.options.Option;
+import org.gradle.initialization.layout.BuildLayout;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import javax.inject.Inject;
+
+public class ExtractCurrentVersionsTask extends AbstractVersionsTask {
+    private static final Logger LOGGER = Logging.getLogger(ExtractCurrentVersionsTask.class);
+
+    private Path outputFile;
+
+    @Inject
+    public ExtractCurrentVersionsTask(BuildLayout layout) {
+        super(layout);
+    }
+
+    @Option(option = "output-file", description = "File to output tag information to")
+    public void outputFile(String file) {
+        this.outputFile = Path.of(file);
+    }
+
+    @TaskAction
+    public void executeTask() throws IOException {
+        if (outputFile == null) {
+            throw new IllegalArgumentException("Output file not specified");
+        }
+
+        LOGGER.lifecycle("Extracting latest version information");
+
+        List<String> output = new ArrayList<>();
+        int transportVersion = readLatestVersion(rootDir.resolve(TRANSPORT_VERSION_FILE_PATH));
+        LOGGER.lifecycle("Transport version: {}", transportVersion);
+        output.add(TRANSPORT_VERSION_TYPE + ":" + transportVersion);
+
+        int indexVersion = readLatestVersion(rootDir.resolve(INDEX_VERSION_FILE_PATH));
+        LOGGER.lifecycle("Index version: {}", indexVersion);
+        output.add(INDEX_VERSION_TYPE + ":" + indexVersion);
+
+        LOGGER.lifecycle("Writing version information to {}", outputFile);
+        Files.write(outputFile, output, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
+    }
+
+    static class FieldIdExtractor implements Consumer<FieldDeclaration> {
+        private Integer highestVersionId;
+
+        Integer highestVersionId() {
+            return highestVersionId;
+        }
+
+        @Override
+        public void accept(FieldDeclaration fieldDeclaration) {
+            var ints = fieldDeclaration.findAll(IntegerLiteralExpr.class);
+            switch (ints.size()) {
+                case 0 -> {
+                    // No ints in the field declaration, ignore
+                }
+                case 1 -> {
+                    int id = ints.get(0).asNumber().intValue();
+                    if (highestVersionId != null && highestVersionId > id) {
+                        LOGGER.warn("Version ids [{}, {}] out of order", highestVersionId, id);
+                    } else {
+                        highestVersionId = id;
+                    }
+                }
+                default -> LOGGER.warn("Multiple integers found in version field declaration [{}]", fieldDeclaration); // and ignore it
+            }
+        }
+    }
+
+    private static int readLatestVersion(Path javaVersionsFile) throws IOException {
+        CompilationUnit java = StaticJavaParser.parse(javaVersionsFile);
+
+        FieldIdExtractor extractor = new FieldIdExtractor();
+        java.walk(FieldDeclaration.class, extractor);   // walks in code file order
+        if (extractor.highestVersionId == null) {
+            throw new IllegalArgumentException("No version ids found in " + javaVersionsFile);
+        }
+        return extractor.highestVersionId;
+    }
+}

+ 3 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/ReleaseToolsPlugin.java

@@ -50,6 +50,9 @@ public class ReleaseToolsPlugin implements Plugin<Project> {
         project.getTasks()
             .register("updateVersions", UpdateVersionsTask.class, t -> project.getTasks().named("spotlessApply").get().mustRunAfter(t));
 
+        project.getTasks().register("extractCurrentVersions", ExtractCurrentVersionsTask.class);
+        project.getTasks().register("tagVersions", TagVersionsTask.class);
+
         final FileTree yamlFiles = projectDirectory.dir("docs/changelog")
             .getAsFileTree()
             .matching(new PatternSet().include("**/*.yml", "**/*.yaml"));

+ 134 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/release/TagVersionsTask.java

@@ -0,0 +1,134 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.elasticsearch.gradle.Version;
+import org.gradle.api.logging.Logger;
+import org.gradle.api.logging.Logging;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.api.tasks.options.Option;
+import org.gradle.initialization.layout.BuildLayout;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import javax.inject.Inject;
+
+public class TagVersionsTask extends AbstractVersionsTask {
+    private static final Logger LOGGER = Logging.getLogger(TagVersionsTask.class);
+
+    private Version releaseVersion;
+
+    private Map<String, Integer> tagVersions = Map.of();
+
+    @Inject
+    public TagVersionsTask(BuildLayout layout) {
+        super(layout);
+    }
+
+    @Option(option = "release", description = "The release version to be tagged")
+    public void release(String version) {
+        releaseVersion = Version.fromString(version);
+    }
+
+    @Option(option = "tag-version", description = "Version id to tag. Of the form <VersionType>:<id>.")
+    public void tagVersions(List<String> version) {
+        this.tagVersions = version.stream().map(l -> {
+            var split = l.split(":");
+            if (split.length != 2) throw new IllegalArgumentException("Invalid tag format [" + l + "]");
+            return split;
+        }).collect(Collectors.toMap(l -> l[0], l -> Integer.parseInt(l[1])));
+    }
+
+    @TaskAction
+    public void executeTask() throws IOException {
+        if (releaseVersion == null) {
+            throw new IllegalArgumentException("Release version not specified");
+        }
+        if (tagVersions.isEmpty()) {
+            throw new IllegalArgumentException("No version tags specified");
+        }
+
+        LOGGER.lifecycle("Tagging version {} component ids", releaseVersion);
+
+        var versions = expandV7Version(tagVersions);
+
+        for (var v : versions.entrySet()) {
+            Path recordFile = switch (v.getKey()) {
+                case TRANSPORT_VERSION_TYPE -> rootDir.resolve(TRANSPORT_VERSIONS_RECORD);
+                case INDEX_VERSION_TYPE -> rootDir.resolve(INDEX_VERSIONS_RECORD);
+                default -> throw new IllegalArgumentException("Unknown version type " + v.getKey());
+            };
+
+            LOGGER.lifecycle("Adding version record for {} to [{}]: [{},{}]", v.getKey(), recordFile, releaseVersion, v.getValue());
+
+            Path file = rootDir.resolve(recordFile);
+            List<String> versionRecords = Files.readAllLines(file);
+            var modified = addVersionRecord(versionRecords, releaseVersion, v.getValue());
+            if (modified.isPresent()) {
+                Files.write(
+                    file,
+                    modified.get(),
+                    StandardOpenOption.CREATE,
+                    StandardOpenOption.WRITE,
+                    StandardOpenOption.TRUNCATE_EXISTING
+                );
+            }
+        }
+    }
+
+    /*
+     * V7 just extracts a single Version. If so, this version needs to be applied to transport and index versions.
+     */
+    private static Map<String, Integer> expandV7Version(Map<String, Integer> tagVersions) {
+        Integer v7Version = tagVersions.get("Version");
+        if (v7Version == null) return tagVersions;
+
+        return Map.of(TRANSPORT_VERSION_TYPE, v7Version, INDEX_VERSION_TYPE, v7Version);
+    }
+
+    private static final Pattern VERSION_LINE = Pattern.compile("(\\d+\\.\\d+\\.\\d+),(\\d+)");
+
+    static Optional<List<String>> addVersionRecord(List<String> versionRecordLines, Version release, int id) {
+        Map<Version, Integer> versions = versionRecordLines.stream().map(l -> {
+            Matcher m = VERSION_LINE.matcher(l);
+            if (m.matches() == false) throw new IllegalArgumentException(String.format("Incorrect format for line [%s]", l));
+            return m;
+        }).collect(Collectors.toMap(m -> Version.fromString(m.group(1)), m -> Integer.parseInt(m.group(2))));
+
+        Integer existing = versions.putIfAbsent(release, id);
+        if (existing != null) {
+            if (existing.equals(id)) {
+                LOGGER.lifecycle("Version id [{}] for release [{}] already recorded", id, release);
+                return Optional.empty();
+            } else {
+                throw new IllegalArgumentException(
+                    String.format(
+                        "Release [%s] already recorded with version id [%s], cannot update to version [%s]",
+                        release,
+                        existing,
+                        id
+                    )
+                );
+            }
+        }
+
+        return Optional.of(
+            versions.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(e -> e.getKey() + "," + e.getValue()).toList()
+        );
+    }
+}

+ 39 - 0
build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/ExtractCurrentVersionsTaskTests.java

@@ -0,0 +1,39 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import com.github.javaparser.StaticJavaParser;
+import com.github.javaparser.ast.body.FieldDeclaration;
+
+import org.elasticsearch.gradle.internal.release.ExtractCurrentVersionsTask.FieldIdExtractor;
+import org.junit.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+public class ExtractCurrentVersionsTaskTests {
+
+    @Test
+    public void testFieldExtractor() {
+        var unit = StaticJavaParser.parse("""
+            public class Version {
+                public static final Version V_1 = def(1);
+                public static final Version V_2 = def(2);
+                public static final Version V_3 = def(3);
+
+                // ignore fields with no or more than one int
+                public static final Version REF = V_3;
+                public static final Version COMPUTED = compute(100, 200);
+            }""");
+
+        FieldIdExtractor extractor = new FieldIdExtractor();
+        unit.walk(FieldDeclaration.class, extractor);
+        assertThat(extractor.highestVersionId(), is(3));
+    }
+}

+ 123 - 0
build-tools-internal/src/test/java/org/elasticsearch/gradle/internal/release/TagVersionsTaskTests.java

@@ -0,0 +1,123 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal.release;
+
+import org.elasticsearch.gradle.Version;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertThrows;
+
+public class TagVersionsTaskTests {
+
+    @Test
+    public void testAddLastRecord() {
+        List<String> startingLines = List.of(
+            "8.0.0,6100",
+            "8.0.1,6100",
+            "8.1.0,6204",
+            "8.2.0,6234",
+            "8.3.0,6239",
+            "8.3.1,6260",
+            "8.4.0,6301"
+        );
+
+        var modified = TagVersionsTask.addVersionRecord(new ArrayList<>(startingLines), Version.fromString("8.4.1"), 6305);
+        assertThat(modified.isPresent(), is(true));
+
+        List<String> expected = new ArrayList<>(startingLines);
+        expected.add("8.4.1,6305");
+        expected.sort(Comparator.naturalOrder());
+        assertThat(modified.get(), contains(expected.toArray()));
+    }
+
+    @Test
+    public void testAddMiddleRecord() {
+        List<String> startingLines = List.of(
+            "8.0.0,6100",
+            "8.0.1,6100",
+            "8.1.0,6204",
+            "8.2.0,6234",
+            "8.3.0,6239",
+            "8.3.1,6260",
+            "8.4.0,6301"
+        );
+
+        var modified = TagVersionsTask.addVersionRecord(new ArrayList<>(startingLines), Version.fromString("8.3.2"), 6280);
+        assertThat(modified.isPresent(), is(true));
+
+        List<String> expected = new ArrayList<>(startingLines);
+        expected.add("8.3.2,6280");
+        expected.sort(Comparator.naturalOrder());
+        assertThat(modified.get(), contains(expected.toArray()));
+    }
+
+    @Test
+    public void testIdempotent() {
+        List<String> startingLines = List.of(
+            "8.0.0,6100",
+            "8.0.1,6100",
+            "8.1.0,6204",
+            "8.2.0,6234",
+            "8.3.1,6260",
+            "8.3.0,6239",
+            "8.4.0,6301"
+        );
+
+        var modified = TagVersionsTask.addVersionRecord(new ArrayList<>(startingLines), Version.fromString("8.4.0"), 6301);
+        assertThat(modified.isPresent(), is(false));
+    }
+
+    @Test
+    public void testFailsConflictingId() {
+        List<String> startingLines = List.of(
+            "8.0.0,6100",
+            "8.0.1,6100",
+            "8.1.0,6204",
+            "8.2.0,6234",
+            "8.3.1,6260",
+            "8.3.0,6239",
+            "8.4.0,6301"
+        );
+
+        var ex = assertThrows(
+            IllegalArgumentException.class,
+            () -> TagVersionsTask.addVersionRecord(new ArrayList<>(startingLines), Version.fromString("8.4.0"), 6302)
+        );
+        assertThat(ex.getMessage(), is("Release [8.4.0] already recorded with version id [6301], cannot update to version [6302]"));
+    }
+
+    @Test
+    public void testFailsIncorrectFormat() {
+        List<String> lines = List.of("8.0.,f4d2");
+
+        var ex = assertThrows(
+            IllegalArgumentException.class,
+            () -> TagVersionsTask.addVersionRecord(new ArrayList<>(lines), Version.fromString("1.0.0"), 1)
+        );
+        assertThat(ex.getMessage(), is("Incorrect format for line [8.0.,f4d2]"));
+    }
+
+    @Test
+    public void testFailsDuplicateVersions() {
+        List<String> lines = List.of("8.0.0,100", "8.0.0,101");
+
+        var ex = assertThrows(
+            IllegalStateException.class,
+            () -> TagVersionsTask.addVersionRecord(new ArrayList<>(lines), Version.fromString("8.0.1"), 102)
+        );
+        assertThat(ex.getMessage(), is("Duplicate key 8.0.0 (attempted merging values 100 and 101)"));
+    }
+}

+ 5 - 0
docs/changelog/103627.yaml

@@ -0,0 +1,5 @@
+pr: 103627
+summary: Add gradle tasks and code to modify and access mappings between version ids and release versions
+area: Infra/Core
+type: feature
+issues: []

+ 133 - 0
server/src/main/java/org/elasticsearch/ReleaseVersions.java

@@ -0,0 +1,133 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch;
+
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.internal.BuildExtension;
+import org.elasticsearch.plugins.ExtensionLoader;
+
+import java.io.BufferedReader;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.NavigableMap;
+import java.util.ServiceLoader;
+import java.util.TreeMap;
+import java.util.function.IntFunction;
+import java.util.regex.Pattern;
+
+public class ReleaseVersions {
+
+    private static final boolean USES_VERSIONS;
+
+    static {
+        USES_VERSIONS = ExtensionLoader.loadSingleton(ServiceLoader.load(BuildExtension.class))
+            .map(BuildExtension::hasReleaseVersioning)
+            .orElse(true);
+    }
+
+    private static final Pattern VERSION_LINE = Pattern.compile("(\\d+\\.\\d+\\.\\d+),(\\d+)");
+
+    public static IntFunction<String> generateVersionsLookup(Class<?> versionContainer) {
+        if (USES_VERSIONS == false) return Integer::toString;
+
+        try {
+            String versionsFileName = versionContainer.getSimpleName() + ".csv";
+            InputStream versionsFile = versionContainer.getResourceAsStream(versionsFileName);
+            if (versionsFile == null) {
+                throw new FileNotFoundException(Strings.format("Could not find versions file for class [%s]", versionContainer));
+            }
+
+            NavigableMap<Integer, List<Version>> versions = new TreeMap<>();
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(versionsFile, StandardCharsets.UTF_8))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    var matcher = VERSION_LINE.matcher(line);
+                    if (matcher.matches() == false) {
+                        throw new IOException(Strings.format("Incorrect format for line [%s] in [%s]", line, versionsFileName));
+                    }
+                    try {
+                        Integer id = Integer.valueOf(matcher.group(2));
+                        Version version = Version.fromString(matcher.group(1));
+                        versions.computeIfAbsent(id, k -> new ArrayList<>()).add(version);
+                    } catch (IllegalArgumentException e) {
+                        // cannot happen??? regex is wrong...
+                        assert false : "Regex allowed non-integer id or incorrect version through: " + e;
+                        throw new IOException(Strings.format("Incorrect format for line [%s] in [%s]", line, versionsFileName), e);
+                    }
+                }
+            }
+
+            // replace all version lists with the smallest & greatest versions
+            versions.replaceAll((k, v) -> {
+                if (v.size() == 1) {
+                    return List.of(v.get(0));
+                } else {
+                    v.sort(Comparator.naturalOrder());
+                    return List.of(v.get(0), v.get(v.size() - 1));
+                }
+            });
+
+            return lookupFunction(versions);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private static IntFunction<String> lookupFunction(NavigableMap<Integer, List<Version>> versions) {
+        assert versions.values().stream().allMatch(vs -> vs.size() == 1 || vs.size() == 2)
+            : "Version ranges have not been properly processed: " + versions;
+
+        return id -> {
+            List<Version> versionRange = versions.get(id);
+
+            String lowerBound, upperBound;
+            if (versionRange != null) {
+                lowerBound = versionRange.get(0).toString();
+                upperBound = lastItem(versionRange).toString();
+            } else {
+                // infer the bounds from the surrounding entries
+                var lowerRange = versions.lowerEntry(id);
+                if (lowerRange != null) {
+                    // the next version is just a guess - might be a newer revision, might be a newer minor or major...
+                    lowerBound = nextVersion(lastItem(lowerRange.getValue())).toString();
+                } else {
+                    // we know about all preceding versions - how can this version be less than everything else we know about???
+                    assert false : "Could not find preceding version for id " + id;
+                    lowerBound = "snapshot[" + id + "]";
+                }
+
+                var upperRange = versions.higherEntry(id);
+                if (upperRange != null) {
+                    // too hard to guess what version this id might be for using the next version - just use it directly
+                    upperBound = upperRange.getValue().get(0).toString();
+                } else {
+                    // likely a version created after the last release tagged version - ok
+                    upperBound = "snapshot[" + id + "]";
+                }
+            }
+
+            return lowerBound.equals(upperBound) ? lowerBound : lowerBound + "-" + upperBound;
+        };
+    }
+
+    private static <T> T lastItem(List<T> list) {
+        return list.get(list.size() - 1);
+    }
+
+    private static Version nextVersion(Version version) {
+        return new Version(version.id + 100);   // +1 to revision
+    }
+}

+ 7 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -20,6 +20,7 @@ import java.util.NavigableMap;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
+import java.util.function.IntFunction;
 
 /**
  * <p>Transport version is used to coordinate compatible wire protocol communication between nodes, at a fine-grained level.  This replaces
@@ -310,6 +311,12 @@ public class TransportVersions {
         return VERSION_IDS.values();
     }
 
+    private static final IntFunction<String> VERSION_LOOKUP = ReleaseVersions.generateVersionsLookup(TransportVersions.class);
+
+    public static String toReleaseVersion(TransportVersion version) {
+        return VERSION_LOOKUP.apply(version.id());
+    }
+
     // no instance
     private TransportVersions() {}
 }

+ 8 - 0
server/src/main/java/org/elasticsearch/index/IndexVersions.java

@@ -9,6 +9,7 @@
 package org.elasticsearch.index;
 
 import org.apache.lucene.util.Version;
+import org.elasticsearch.ReleaseVersions;
 import org.elasticsearch.core.Assertions;
 import org.elasticsearch.core.UpdateForV9;
 
@@ -21,6 +22,7 @@ import java.util.NavigableMap;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
+import java.util.function.IntFunction;
 
 @SuppressWarnings("deprecation")
 public class IndexVersions {
@@ -204,4 +206,10 @@ public class IndexVersions {
     static Collection<IndexVersion> getAllVersions() {
         return VERSION_IDS.values();
     }
+
+    private static final IntFunction<String> VERSION_LOOKUP = ReleaseVersions.generateVersionsLookup(IndexVersions.class);
+
+    public static String toReleaseVersion(IndexVersion version) {
+        return VERSION_LOOKUP.apply(version.id());
+    }
 }

+ 7 - 0
server/src/main/java/org/elasticsearch/internal/BuildExtension.java

@@ -19,4 +19,11 @@ public interface BuildExtension {
      * Returns the {@link Build} that represents the running Elasticsearch code.
      */
     Build getCurrentBuild();
+
+    /**
+     * {@code true} if this build uses release versions.
+     */
+    default boolean hasReleaseVersioning() {
+        return true;
+    }
 }

+ 52 - 0
server/src/main/resources/org/elasticsearch/TransportVersions.csv

@@ -0,0 +1,52 @@
+7.0.0,7000099
+7.0.1,7000199
+7.1.0,7010099
+7.2.0,7020099
+7.2.1,7020199
+7.3.0,7030099
+7.3.2,7030299
+7.4.0,7040099
+7.5.0,7050099
+7.6.0,7060099
+7.7.0,7070099
+7.8.0,7080099
+7.8.1,7080199
+7.9.0,7090099
+7.10.0,7100099
+7.10.1,7100199
+7.11.0,7110099
+7.12.0,7120099
+7.13.0,7130099
+7.14.0,7140099
+7.15.0,7150099
+7.15.1,7150199
+7.16.0,7160099
+7.17.0,7170099
+7.17.1,7170199
+7.17.8,7170899
+8.0.0,8000099
+8.1.0,8010099
+8.2.0,8020099
+8.3.0,8030099
+8.4.0,8040099
+8.5.0,8050099
+8.6.0,8060099
+8.6.1,8060199
+8.7.0,8070099
+8.7.1,8070199
+8.8.0,8080099
+8.8.1,8080199
+8.9.0,8500020
+8.9.1,8500020
+8.9.2,8500020
+8.10.0,8500061
+8.10.1,8500061
+8.10.2,8500061
+8.10.3,8500061
+8.10.4,8500061
+8.11.0,8512001
+8.11.1,8512001
+8.11.2,8512001
+8.11.3,8512001
+8.11.4,8512001
+8.12.0,8560000

+ 53 - 0
server/src/main/resources/org/elasticsearch/index/IndexVersions.csv

@@ -0,0 +1,53 @@
+7.0.0,7000099
+7.0.1,7000199
+7.1.0,7010099
+7.2.0,7020099
+7.2.1,7020199
+7.3.0,7030099
+7.3.2,7030299
+7.4.0,7040099
+7.5.0,7050099
+7.6.0,7060099
+7.7.0,7070099
+7.8.0,7080099
+7.8.1,7080199
+7.9.0,7090099
+7.10.0,7100099
+7.10.1,7100199
+7.11.0,7110099
+7.12.0,7120099
+7.13.0,7130099
+7.14.0,7140099
+7.15.0,7150099
+7.15.1,7150199
+7.16.0,7160099
+7.17.0,7170099
+7.17.1,7170199
+7.17.8,7170899
+8.0.0,8000099
+8.1.0,8010099
+8.2.0,8020099
+8.3.0,8030099
+8.4.0,8040099
+8.5.0,8050099
+8.6.0,8060099
+8.6.1,8060199
+8.7.0,8070099
+8.7.1,8070199
+8.8.0,8080099
+8.8.1,8080199
+8.8.2,8080299
+8.9.0,8090099
+8.9.1,8090199
+8.9.2,8090299
+8.10.0,8100099
+8.10.1,8100199
+8.10.2,8100299
+8.10.3,8100399
+8.10.4,8100499
+8.11.0,8500003
+8.11.1,8500003
+8.11.2,8500003
+8.11.3,8500003
+8.11.4,8500003
+8.12.0,8500008

+ 35 - 0
server/src/test/java/org/elasticsearch/ReleaseVersionsTests.java

@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch;
+
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.function.IntFunction;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class ReleaseVersionsTests extends ESTestCase {
+
+    public void testReleaseVersions() {
+        IntFunction<String> versions = ReleaseVersions.generateVersionsLookup(ReleaseVersionsTests.class);
+
+        assertThat(versions.apply(10), equalTo("8.0.0"));
+        assertThat(versions.apply(14), equalTo("8.1.0-8.1.1"));
+        assertThat(versions.apply(21), equalTo("8.2.0"));
+        assertThat(versions.apply(22), equalTo("8.2.1"));
+    }
+
+    public void testReturnsRange() {
+        IntFunction<String> versions = ReleaseVersions.generateVersionsLookup(ReleaseVersionsTests.class);
+
+        assertThat(versions.apply(17), equalTo("8.1.2-8.2.0"));
+        expectThrows(AssertionError.class, () -> versions.apply(9));
+        assertThat(versions.apply(24), equalTo("8.2.2-snapshot[24]"));
+    }
+}

+ 5 - 0
server/src/test/resources/org/elasticsearch/ReleaseVersionsTests.csv

@@ -0,0 +1,5 @@
+8.0.0,10
+8.1.0,14
+8.1.1,14
+8.2.0,21
+8.2.1,22