From ebf470acdf645b92dcc82e492699da5df3f6ab39 Mon Sep 17 00:00:00 2001 From: Mark Vieira Date: Tue, 3 Dec 2024 13:04:02 -0800 Subject: [PATCH] Provide a mechanism to modify config files in a running test cluster (#117859) --- .../local/AbstractLocalClusterFactory.java | 103 +++++++++++++----- .../util/resource/MutableResource.java | 53 +++++++++ .../xpack/eql/EqlSecurityTestCluster.java | 2 +- 3 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 test/test-clusters/src/main/java/org/elasticsearch/test/cluster/util/resource/MutableResource.java diff --git a/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java b/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java index 2dac2ee232aa5..6070ec140d254 100644 --- a/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java +++ b/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/local/AbstractLocalClusterFactory.java @@ -26,6 +26,8 @@ import org.elasticsearch.test.cluster.util.ProcessUtils; import org.elasticsearch.test.cluster.util.Retry; import org.elasticsearch.test.cluster.util.Version; +import org.elasticsearch.test.cluster.util.resource.MutableResource; +import org.elasticsearch.test.cluster.util.resource.Resource; import java.io.BufferedInputStream; import java.io.BufferedReader; @@ -115,6 +117,9 @@ public static class Node { private Version currentVersion; private Process process = null; private DistributionDescriptor distributionDescriptor; + private Set extraConfigListeners = new HashSet<>(); + private Set keystoreFileListeners = new HashSet<>(); + private Set roleFileListeners = new HashSet<>(); public Node(Path baseWorkingDir, DistributionResolver distributionResolver, LocalNodeSpec spec) { this(baseWorkingDir, distributionResolver, spec, null, false); @@ -436,6 +441,10 @@ private void writeConfiguration() { private void copyExtraConfigFiles() { spec.getExtraConfigFiles().forEach((fileName, resource) -> { + if (fileName.equals("roles.yml")) { + throw new IllegalArgumentException("Security roles should be configured via 'rolesFile()' method."); + } + final Path target = configDir.resolve(fileName); final Path directory = target.getParent(); if (Files.exists(directory) == false) { @@ -446,6 +455,14 @@ private void copyExtraConfigFiles() { } } resource.writeTo(target); + + // Register and update listener for this config file + if (resource instanceof MutableResource && extraConfigListeners.add(fileName)) { + ((MutableResource) resource).addUpdateListener(updated -> { + LOGGER.info("Updating config file '{}'", fileName); + updated.writeTo(target); + }); + } }); } @@ -485,29 +502,39 @@ private void addKeystoreSettings() { private void addKeystoreFiles() { spec.getKeystoreFiles().forEach((key, file) -> { - try { - Path path = Files.createTempFile(tempDir, key, null); - file.writeTo(path); - - ProcessUtils.exec( - spec.getKeystorePassword(), - workingDir, - OS.conditional( - c -> c.onWindows(() -> distributionDir.resolve("bin").resolve("elasticsearch-keystore.bat")) - .onUnix(() -> distributionDir.resolve("bin").resolve("elasticsearch-keystore")) - ), - getEnvironmentVariables(), - false, - "add-file", - key, - path.toString() - ).waitFor(); - } catch (InterruptedException | IOException e) { - throw new RuntimeException(e); + addKeystoreFile(key, file); + if (file instanceof MutableResource && keystoreFileListeners.add(key)) { + ((MutableResource) file).addUpdateListener(updated -> { + LOGGER.info("Updating keystore file '{}'", key); + addKeystoreFile(key, updated); + }); } }); } + private void addKeystoreFile(String key, Resource file) { + try { + Path path = Files.createTempFile(tempDir, key, null); + file.writeTo(path); + + ProcessUtils.exec( + spec.getKeystorePassword(), + workingDir, + OS.conditional( + c -> c.onWindows(() -> distributionDir.resolve("bin").resolve("elasticsearch-keystore.bat")) + .onUnix(() -> distributionDir.resolve("bin").resolve("elasticsearch-keystore")) + ), + getEnvironmentVariables(), + false, + "add-file", + key, + path.toString() + ).waitFor(); + } catch (InterruptedException | IOException e) { + throw new RuntimeException(e); + } + } + private void writeSecureSecretsFile() { if (spec.getKeystoreFiles().isEmpty() == false) { throw new IllegalStateException( @@ -535,16 +562,20 @@ private void configureSecurity() { if (spec.isSecurityEnabled()) { if (spec.getUsers().isEmpty() == false) { LOGGER.info("Setting up roles.yml for node '{}'", name); - - Path destination = workingDir.resolve("config").resolve("roles.yml"); - spec.getRolesFiles().forEach(rolesFile -> { - try ( - Writer writer = Files.newBufferedWriter(destination, StandardOpenOption.APPEND); - Reader reader = new BufferedReader(new InputStreamReader(rolesFile.asStream())) - ) { - reader.transferTo(writer); - } catch (IOException e) { - throw new UncheckedIOException("Failed to append roles file " + rolesFile + " to " + destination, e); + writeRolesFile(); + spec.getRolesFiles().forEach(resource -> { + if (resource instanceof MutableResource && roleFileListeners.add(resource)) { + ((MutableResource) resource).addUpdateListener(updated -> { + LOGGER.info("Updating roles.yml for node '{}'", name); + Path rolesFile = workingDir.resolve("config").resolve("roles.yml"); + try { + Files.delete(rolesFile); + Files.copy(distributionDir.resolve("config").resolve("roles.yml"), rolesFile); + writeRolesFile(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); } }); } @@ -596,6 +627,20 @@ private void configureSecurity() { } } + private void writeRolesFile() { + Path destination = workingDir.resolve("config").resolve("roles.yml"); + spec.getRolesFiles().forEach(rolesFile -> { + try ( + Writer writer = Files.newBufferedWriter(destination, StandardOpenOption.APPEND); + Reader reader = new BufferedReader(new InputStreamReader(rolesFile.asStream())) + ) { + reader.transferTo(writer); + } catch (IOException e) { + throw new UncheckedIOException("Failed to append roles file " + rolesFile + " to " + destination, e); + } + }); + } + private void installPlugins() { if (spec.getPlugins().isEmpty() == false) { Pattern pattern = Pattern.compile("(.+)(?:-\\d+\\.\\d+\\.\\d+(-SNAPSHOT)?\\.zip)"); diff --git a/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/util/resource/MutableResource.java b/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/util/resource/MutableResource.java new file mode 100644 index 0000000000000..477ad82e5944a --- /dev/null +++ b/test/test-clusters/src/main/java/org/elasticsearch/test/cluster/util/resource/MutableResource.java @@ -0,0 +1,53 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.test.cluster.util.resource; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * A mutable version of {@link Resource}. Anywhere a {@link Resource} is accepted in the test clusters API a {@link MutableResource} can + * be supplied instead. Unless otherwise specified, when the {@link #update(Resource)} method is called, the backing configuration will + * be updated in-place. + */ +public class MutableResource implements Resource { + private final List> listeners = new ArrayList<>(); + private Resource delegate; + + private MutableResource(Resource delegate) { + this.delegate = delegate; + } + + @Override + public InputStream asStream() { + return delegate.asStream(); + } + + public static MutableResource from(Resource delegate) { + return new MutableResource(delegate); + } + + public void update(Resource delegate) { + this.delegate = delegate; + this.listeners.forEach(listener -> listener.accept(this)); + } + + /** + * Registers a listener that will be notified when any updates are made to this resource. This listener will receive a reference to + * the resource with the updated value. + * + * @param listener action to be called on update + */ + public synchronized void addUpdateListener(Consumer listener) { + listeners.add(listener); + } +} diff --git a/x-pack/plugin/eql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/eql/EqlSecurityTestCluster.java b/x-pack/plugin/eql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/eql/EqlSecurityTestCluster.java index a1a417d91aeb8..33f048d81ef52 100644 --- a/x-pack/plugin/eql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/eql/EqlSecurityTestCluster.java +++ b/x-pack/plugin/eql/qa/security/src/javaRestTest/java/org/elasticsearch/xpack/eql/EqlSecurityTestCluster.java @@ -19,7 +19,7 @@ public static ElasticsearchCluster getCluster() { .setting("xpack.license.self_generated.type", "basic") .setting("xpack.monitoring.collection.enabled", "true") .setting("xpack.security.enabled", "true") - .configFile("roles.yml", Resource.fromClasspath("roles.yml")) + .rolesFile(Resource.fromClasspath("roles.yml")) .user("test-admin", "x-pack-test-password", "test-admin", false) .user("user1", "x-pack-test-password", "user1", false) .user("user2", "x-pack-test-password", "user2", false)