This is an automated email from the ASF dual-hosted git repository.

keith-turner pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/accumulo.git


The following commit(s) were added to refs/heads/main by this push:
     new 7be0b680e2 Adds expected file to import config command (#6342)
7be0b680e2 is described below

commit 7be0b680e27d8939b95917c1d0024fdf06bbfd88
Author: Keith Turner <[email protected]>
AuthorDate: Tue Oct 27 16:57:28 2026 -0400

    Adds expected file to import config command (#6342)
    
    Adds a new option to the import config command to provide an optional
    expected file. If the expected file is provided, then updates are only
    made if the expected file equals the current properties in zookeeper.
    The expected check is done atomically.
    
    ```bash
    accumulo conf export > export.yaml
    cp export.yaml copy.yaml
    vim copy.yaml // edit config
    accumulo conf import --input copy.yaml --expected export.yaml
    ```
    
    The import would fail if any config changes were made after the export.
    Added this new option as a safer way to do what zoo-prop-editor does,
    plan to deprecate or remove zoo-prop-editor after this is merged.
    
    Another use case this new option supports is detecting changes between
    pushing updates from CM.  For example if a new config update needs to
    applied from CM, can check if the previous update from CM is still as
    is.  This would make the import command fail if any changes were made
    outside of CM.
    
    ```bash
    accumulo conf import --input new-from-cm.yaml --expected prev-from-cm.yaml
    ```
---
 .../accumulo/server/conf/store/PropStore.java      |  15 ++
 .../server/conf/store/impl/ZooPropStore.java       |  15 ++
 .../server/conf/util/ImportConfigCommand.java      | 121 +++++++++-
 test/pom.xml                                       |   4 +
 .../accumulo/test/conf/ImportExportConfigIT.java   | 252 +++++++++++++++++++++
 5 files changed, 399 insertions(+), 8 deletions(-)

diff --git 
a/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropStore.java
 
b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropStore.java
index 74f89e806a..7567bd7341 100644
--- 
a/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropStore.java
+++ 
b/server/base/src/main/java/org/apache/accumulo/server/conf/store/PropStore.java
@@ -82,6 +82,21 @@ public interface PropStore {
   void replaceAll(PropStoreKey propStoreKey, long version, Map<String,String> 
props)
       throws ConcurrentModificationException;
 
+  /**
+   * Replaces all current properties with map provided. If a property is not 
included in the new
+   * map, the property will not be set.
+   *
+   * @param propStoreKey the prop cache key
+   * @param expected expected current properties
+   * @param props a map of property k,v pairs
+   * @throws IllegalStateException if the values cannot be written or if an 
underlying store
+   *         exception occurs.
+   * @throws java.util.ConcurrentModificationException if the properties 
currently in the store do
+   *         not match the expected props passed. No changes are made when 
this happens.
+   */
+  void replaceAll(PropStoreKey propStoreKey, Map<String,String> expected, 
Map<String,String> props)
+      throws ConcurrentModificationException;
+
   /**
    * Unconditionally replaces all properties with the map provided. If a 
property is not included in
    * the new map, the property will not be set.
diff --git 
a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropStore.java
 
b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropStore.java
index 91b068d5b7..9cf106d583 100644
--- 
a/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropStore.java
+++ 
b/server/base/src/main/java/org/apache/accumulo/server/conf/store/impl/ZooPropStore.java
@@ -220,6 +220,21 @@ public class ZooPropStore implements PropStore, 
PropChangeListener {
     mutateVersionedProps(propStoreKey, VersionedProperties::replaceAll, props);
   }
 
+  @Override
+  public void replaceAll(@NonNull PropStoreKey propStoreKey, 
Map<String,String> expected,
+      @NonNull Map<String,String> props) {
+    BiFunction<VersionedProperties,Map<String,String>,VersionedProperties> 
action =
+        (current, p) -> {
+          if (!current.asMap().equals(expected)) {
+            throw new ConcurrentModificationException(
+                "Current properties do not match expected, key:" + 
propStoreKey);
+          }
+          return current.replaceAll(p);
+        };
+
+    mutateVersionedProps(propStoreKey, action, props);
+  }
+
   @Override
   public void replaceAll(@NonNull PropStoreKey propStoreKey, long version,
       @NonNull Map<String,String> props) {
diff --git 
a/server/base/src/main/java/org/apache/accumulo/server/conf/util/ImportConfigCommand.java
 
b/server/base/src/main/java/org/apache/accumulo/server/conf/util/ImportConfigCommand.java
index 5c926cf761..60b1a19961 100644
--- 
a/server/base/src/main/java/org/apache/accumulo/server/conf/util/ImportConfigCommand.java
+++ 
b/server/base/src/main/java/org/apache/accumulo/server/conf/util/ImportConfigCommand.java
@@ -18,8 +18,15 @@
  */
 package org.apache.accumulo.server.conf.util;
 
+import java.io.BufferedInputStream;
+import java.io.IOException;
 import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.ConcurrentModificationException;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -44,6 +51,7 @@ import org.apache.accumulo.start.spi.CommandGroups;
 import org.apache.accumulo.start.spi.KeywordExecutable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.yaml.snakeyaml.LoaderOptions;
 import org.yaml.snakeyaml.Yaml;
 
 import com.beust.jcommander.JCommander;
@@ -77,6 +85,12 @@ public class ImportConfigCommand extends 
ServerKeywordExecutable<ImportConfigCom
   }
 
   public static class Opts extends ServerOpts {
+    @Parameter(names = "--input",
+        description = "Yaml file containing configuration data. If not 
specified will read from stdin.")
+    public String inputFile;
+    @Parameter(names = "--expected",
+        description = "Yaml file containing expected current config.  Changes 
are only made if config in zookeeper matches whats in this file.")
+    public String expectedFile;
     @Parameter(names = "--ignore-extra",
         description = "Proceed when Accumulo has extra tables, resource 
groups, or namespaces that are not in yaml")
     public boolean ignoreExtra = false;
@@ -99,6 +113,9 @@ public class ImportConfigCommand extends 
ServerKeywordExecutable<ImportConfigCom
   }
 
   record ScopeName(Scope scope, String name) {
+    public ScopeName(ScopedProperties sp) {
+      this(sp.scope(), sp.name());
+    }
   }
 
   private static Set<ScopeName> getAllScopeNames(ServerContext context) {
@@ -124,9 +141,15 @@ public class ImportConfigCommand extends 
ServerKeywordExecutable<ImportConfigCom
   }
 
   private static void validate(ServerContext serverContext, 
List<ScopedProperties> allProps,
-      boolean ignoreExtra) {
+      Map<ScopeName,ScopedProperties> expectedProps, boolean ignoreExtra,
+      boolean precheckExpected) {
     var scopeNamesInYaml = new HashSet<ScopeName>();
-    allProps.forEach(sp -> scopeNamesInYaml.add(new ScopeName(sp.scope(), 
sp.name())));
+    allProps.forEach(sp -> {
+      if (!scopeNamesInYaml.add(new ScopeName(sp))) {
+        throw new IllegalArgumentException(
+            "Duplicate scope+name in input, scope:" + sp.scope() + " name:" + 
sp.name());
+      }
+    });
     var scopeNamesInAccumulo = getAllScopeNames(serverContext);
 
     if (!scopeNamesInYaml.equals(scopeNamesInAccumulo)) {
@@ -149,29 +172,111 @@ public class ImportConfigCommand extends 
ServerKeywordExecutable<ImportConfigCom
       }
     }
 
+    if (expectedProps != null) {
+      for (var scopedProps : allProps) {
+        var key = new ScopeName(scopedProps.scope(), scopedProps.name());
+        if (!expectedProps.containsKey(key)) {
+          throw new IllegalArgumentException(
+              "Scope+name present in input but not present in expected file, 
scope:" + key.scope()
+                  + " name:" + key.name());
+        }
+      }
+    }
+
     // validate all scope+name before attempting to update any scope+name
+    var propStore = serverContext.getPropStore();
     for (var scopedProps : allProps) {
       var propStoreKey = getKey(scopedProps.scope(), scopedProps.name(), 
serverContext);
       PropUtil.validateProperties(serverContext, propStoreKey, 
scopedProps.props());
+      // precheckExpected is only used in testing, it allows test code to 
bypass this code and
+      // exercise the expected check done in the atomic zookeeper update.
+      if (expectedProps != null && precheckExpected) {
+        // This check serves two purposes. First it runs during dry-run. 
Second it avoids changing
+        // anything when a subset does not match the expected. Changes could 
be made after this
+        // check and before the update. If this happens the update still fail 
because another check
+        // is done in the atomic zookeeper update, however that could lead to 
a subset that match
+        // the expected being updated.
+        if (!propStore.get(propStoreKey).asMap()
+            .equals(expectedProps.get(new ScopeName(scopedProps)).props())) {
+          throw new ConcurrentModificationException(
+              createUnexpectedMessage(scopedProps.scope(), 
scopedProps.name()));
+        }
+      }
+    }
+  }
+
+  private static List<ScopedProperties> read(Yaml yaml, String file, 
InputStream in) {
+    List<ScopedProperties> allProps = new ArrayList<>();
+    if (file != null) {
+      try (var fileIn = new 
BufferedInputStream(Files.newInputStream(Path.of(file)))) {
+        for (var obj : yaml.loadAll(fileIn)) {
+          allProps.add(new ScopedProperties((Map<?,?>) obj));
+        }
+      } catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+    } else {
+      for (var obj : yaml.loadAll(in)) {
+        allProps.add(new ScopedProperties((Map<?,?>) obj));
+      }
     }
+
+    return allProps;
   }
 
   @VisibleForTesting
   public static void load(ServerContext serverContext, InputStream in, Opts 
options) {
-    Yaml yaml = new Yaml();
-    List<ScopedProperties> allProps = new ArrayList<>();
-    for (var obj : yaml.loadAll(in)) {
-      allProps.add(new ScopedProperties((Map<?,?>) obj));
+    load(serverContext, in, options, true);
+  }
+
+  private static String createUnexpectedMessage(Scope scope, String name) {
+    return "Properties in scope:" + scope + " name:" + name
+        + " do not match the expected values. To diagnose, export current 
config to a new file and diff with expected file.";
+  }
+
+  @VisibleForTesting
+  public static void load(ServerContext serverContext, InputStream in, Opts 
options,
+      boolean precheckExpected) {
+    var loaderOpts = new LoaderOptions();
+    loaderOpts.setAllowDuplicateKeys(false);
+    Yaml yaml = new Yaml(loaderOpts);
+    List<ScopedProperties> allProps = read(yaml, options.inputFile, in);
+
+    Map<ScopeName,ScopedProperties> expectedProps;
+    if (options.expectedFile == null) {
+      expectedProps = null;
+    } else {
+      var grouped = new HashMap<ScopeName,ScopedProperties>();
+      for (var sp : read(yaml, options.expectedFile, null)) {
+        var key = new ScopeName(sp);
+        if (grouped.put(key, sp) != null) {
+          throw new IllegalArgumentException(
+              "Duplicate scope+name in expected file, scope:" + sp.scope() + " 
name:" + sp.name());
+        }
+      }
+      expectedProps = grouped;
     }
 
-    validate(serverContext, allProps, options.ignoreExtra);
+    validate(serverContext, allProps, expectedProps, options.ignoreExtra, 
precheckExpected);
 
     if (!options.dryRun) {
       var propStore = serverContext.getPropStore();
 
       for (var sp : allProps) {
         var propStoreKey = getKey(sp.scope(), sp.name(), serverContext);
-        propStore.replaceAll(propStoreKey, sp.props());
+        if (expectedProps == null) {
+          // Unconditionally replace properties
+          propStore.replaceAll(propStoreKey, sp.props());
+        } else {
+          try {
+            // Only replace properties if they match the expected values
+            propStore.replaceAll(propStoreKey, expectedProps.get(new 
ScopeName(sp)).props(),
+                sp.props());
+          } catch (ConcurrentModificationException cme) {
+            throw new ConcurrentModificationException(
+                createUnexpectedMessage(sp.scope(), sp.name()), cme);
+          }
+        }
       }
     }
   }
diff --git a/test/pom.xml b/test/pom.xml
index d04bc6a650..fff7ad588f 100644
--- a/test/pom.xml
+++ b/test/pom.xml
@@ -236,6 +236,10 @@
       <groupId>org.slf4j</groupId>
       <artifactId>slf4j-api</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.yaml</groupId>
+      <artifactId>snakeyaml</artifactId>
+    </dependency>
     <dependency>
       <groupId>io.opentelemetry.javaagent</groupId>
       <artifactId>opentelemetry-javaagent</artifactId>
diff --git 
a/test/src/main/java/org/apache/accumulo/test/conf/ImportExportConfigIT.java 
b/test/src/main/java/org/apache/accumulo/test/conf/ImportExportConfigIT.java
index 5cbed9f686..d631b49376 100644
--- a/test/src/main/java/org/apache/accumulo/test/conf/ImportExportConfigIT.java
+++ b/test/src/main/java/org/apache/accumulo/test/conf/ImportExportConfigIT.java
@@ -21,11 +21,18 @@ package org.apache.accumulo.test.conf;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.ByteArrayInputStream;
+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.ConcurrentModificationException;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -45,11 +52,20 @@ import 
org.apache.accumulo.miniclusterImpl.MiniAccumuloConfigImpl;
 import org.apache.accumulo.server.conf.util.ExportConfigCommand;
 import org.apache.accumulo.server.conf.util.ImportConfigCommand;
 import org.apache.accumulo.test.harness.AccumuloClusterHarness;
+import org.apache.accumulo.test.util.Wait;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.RawLocalFileSystem;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.yaml.snakeyaml.constructor.DuplicateKeyException;
 
 public class ImportExportConfigIT extends AccumuloClusterHarness {
+
+  @TempDir
+  private static Path tempDir;
+
   private static final String YAML1 =
       """
           scope: SYSTEM
@@ -765,6 +781,242 @@ public class ImportExportConfigIT extends 
AccumuloClusterHarness {
 
   }
 
+  /**
+   * Test when yaml has duplicate scope+name, should fail.
+   */
+  @Test
+  public void testDuplicate() throws Exception {
+
+    var template = """
+        scope: __SCOPE__
+        name: dup
+        properties: {
+          table.split.threshold: 5
+        }
+        ---
+        scope: __SCOPE__
+        name: dup
+        properties: {
+          table.split.threshold: 7
+        }
+        """;
+
+    var correctTemplate = """
+        scope: __SCOPE__
+        name: dup
+        properties: {
+          table.split.threshold: 5
+        }
+        """;
+
+    for (var scope : ExportConfigCommand.Scope.values()) {
+      var yaml = template.replace("__SCOPE__", scope.name());
+      var iae = assertThrows(IllegalArgumentException.class,
+          () -> ImportConfigCommand.load(getServerContext(),
+              new ByteArrayInputStream(yaml.getBytes(UTF_8)), 
DRY_RUN_IGNORE_EXTRA_OPTS));
+      assertEquals("Duplicate scope+name in input, scope:" + scope.name() + " 
name:dup",
+          iae.getMessage());
+
+      var opts = new ImportConfigCommand.Opts();
+      opts.expectedFile = write(yaml);
+      opts.dryRun = true;
+      opts.ignoreExtra = true;
+      var correctYaml = correctTemplate.replace("__SCOPE__", scope.name());
+      iae = assertThrows(IllegalArgumentException.class, () -> 
ImportConfigCommand
+          .load(getServerContext(), new 
ByteArrayInputStream(correctYaml.getBytes(UTF_8)), opts));
+      assertEquals("Duplicate scope+name in expected file, scope:" + 
scope.name() + " name:dup",
+          iae.getMessage());
+
+    }
+
+    // Test snake yaml settings dissallow duplicate map keys
+    var dupMapKeys = """
+        scope: SYSTEM
+        scope: RESOURCE_GROUP
+        name: ''
+        name: "rgid1"
+        properties: {
+        }
+        """;
+    var opts = new ImportConfigCommand.Opts();
+    opts.expectedFile = write(dupMapKeys);
+    opts.dryRun = true;
+    opts.ignoreExtra = true;
+    assertThrows(DuplicateKeyException.class, () -> 
ImportConfigCommand.load(getServerContext(),
+        new ByteArrayInputStream(dupMapKeys.getBytes(UTF_8)), opts));
+
+  }
+
+  private String write(String yaml) throws IOException {
+    var expectedFile = Files.createTempFile(tempDir, "iet", "yaml");
+    try (var out = Files.newBufferedWriter(expectedFile, 
StandardOpenOption.CREATE,
+        StandardOpenOption.TRUNCATE_EXISTING)) {
+      out.write(yaml);
+    }
+    return expectedFile.toAbsolutePath().toString();
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testExpected(boolean precheckExpected) throws Exception {
+
+    var opts = new ImportConfigCommand.Opts();
+    opts.ignoreExtra = true;
+
+    try (var client = Accumulo.newClient().from(getClientProps()).build()) {
+
+      assertEquals(Map.of(), 
client.instanceOperations().getSystemProperties());
+
+      var expectedSystem = """
+          scope: SYSTEM
+          name: ''
+          properties: {
+          }
+          """;
+      var updateSystem1 = """
+          scope: SYSTEM
+          name: ''
+          properties: {
+            general.server.threadpool.size: 5
+          }
+          """;
+      var updateSystem2 = """
+          scope: SYSTEM
+          name: ''
+          properties: {
+            general.micrometer.log.metrics: log4j2,
+            general.micrometer.enabled: true
+          }
+          """;
+      assertEquals(Map.of(), 
client.instanceOperations().getSystemProperties());
+      opts.expectedFile = write(expectedSystem);
+      opts.inputFile = write(updateSystem1);
+      ImportConfigCommand.load(getServerContext(), new 
ByteArrayInputStream("".getBytes(UTF_8)),
+          opts, precheckExpected);
+      Wait.waitFor(() -> Map.of("general.server.threadpool.size", "5")
+          .equals(client.instanceOperations().getSystemProperties()));
+      // running the command again should fail because the expected is not 
correct
+      opts.expectedFile = write(expectedSystem);
+      opts.inputFile = write(updateSystem2);
+      var cme = assertThrows(ConcurrentModificationException.class,
+          () -> ImportConfigCommand.load(getServerContext(),
+              new ByteArrayInputStream("".getBytes(UTF_8)), opts, 
precheckExpected));
+      assertEquals(
+          "Properties in scope:SYSTEM name: do not match the expected values. 
To diagnose, export current config to a new file and diff with expected file.",
+          cme.getMessage());
+      if (precheckExpected) {
+        assertNull(cme.getCause());
+      } else {
+        // when a failure happens in the zookeeper atomic update will have an 
exception wrapping an
+        // exception
+        assertNotNull(cme.getCause());
+        assertEquals(ConcurrentModificationException.class, 
cme.getCause().getClass());
+      }
+
+      // test dry run with expected
+      opts.dryRun = true;
+      if (precheckExpected) {
+        cme = assertThrows(ConcurrentModificationException.class,
+            () -> ImportConfigCommand.load(getServerContext(),
+                new ByteArrayInputStream("".getBytes(UTF_8)), opts, 
precheckExpected));
+        assertEquals(
+            "Properties in scope:SYSTEM name: do not match the expected 
values. To diagnose, export current config to a new file and diff with expected 
file.",
+            cme.getMessage());
+      } else {
+        // should not fail and should not change anything, this is testing the 
test code.
+        ImportConfigCommand.load(getServerContext(), new 
ByteArrayInputStream("".getBytes(UTF_8)),
+            opts, precheckExpected);
+      }
+      opts.dryRun = false;
+
+      // The properties should not have changed, give any changes that may 
have been erroneously
+      // made time to propagate
+      Thread.sleep(100);
+      assertEquals(Map.of("general.server.threadpool.size", "5"),
+          client.instanceOperations().getSystemProperties());
+
+      // running the import again should succeed w/ the new expected file
+      opts.expectedFile = write(updateSystem1);
+      opts.inputFile = write(updateSystem2);
+      ImportConfigCommand.load(getServerContext(), new 
ByteArrayInputStream("".getBytes(UTF_8)),
+          opts, precheckExpected);
+      Wait.waitFor(() -> Map
+          .of("general.micrometer.log.metrics", "log4j2", 
"general.micrometer.enabled", "true")
+          .equals(client.instanceOperations().getSystemProperties()));
+
+      // test resource groups, namespaces and tables
+      ResourceGroupId rgid1 = ResourceGroupId.of("expectedRG");
+      client.resourceGroupOperations().create(rgid1);
+      assertEquals(Map.of(), 
client.resourceGroupOperations().getProperties(rgid1));
+      client.resourceGroupOperations().setProperty(rgid1, 
"general.server.threadpool.size",
+          "987654321");
+      client.resourceGroupOperations().setProperty(rgid1, 
"general.micrometer.enabled", "false");
+      client.namespaceOperations().create("expns");
+      client.namespaceOperations().setProperty("expns", "table.file.max", 
"50");
+      client.tableOperations().create("expns.t1", new 
NewTableConfiguration().withoutDefaults());
+      client.tableOperations().setProperty("expns.t1", 
"table.split.threshold", "100M");
+      var exportYaml = ExportConfigCommand.export(getServerContext());
+      client.resourceGroupOperations().setProperty(rgid1, 
"general.micrometer.enabled", "true");
+      opts.expectedFile = write(exportYaml);
+      opts.inputFile = write(exportYaml.replace("9", "10"));
+      cme = assertThrows(ConcurrentModificationException.class,
+          () -> ImportConfigCommand.load(getServerContext(),
+              new ByteArrayInputStream("".getBytes(UTF_8)), opts, 
precheckExpected));
+      assertEquals(
+          "Properties in scope:RESOURCE_GROUP name:expectedRG do not match the 
expected values. To diagnose, export current config to a new file and diff with 
expected file.",
+          cme.getMessage());
+      // The properties should not have changed, give any changes that may 
have been erroneously
+      // made time to propagate
+      Thread.sleep(100);
+      assertEquals(Map.of("general.server.threadpool.size", "987654321",
+          "general.micrometer.enabled", "true"),
+          client.resourceGroupOperations().getProperties(rgid1));
+      // correct the expected file to work and run again
+      client.resourceGroupOperations().setProperty(rgid1, 
"general.micrometer.enabled", "false");
+      opts.expectedFile = write(exportYaml);
+      opts.inputFile = write(exportYaml.replace("987654321", "10"));
+      ImportConfigCommand.load(getServerContext(), new 
ByteArrayInputStream("".getBytes(UTF_8)),
+          opts, precheckExpected);
+      Wait.waitFor(() -> Map
+          .of("general.server.threadpool.size", "10", 
"general.micrometer.enabled", "false")
+          .equals(client.resourceGroupOperations().getProperties(rgid1)));
+
+      // check table scope
+      exportYaml = ExportConfigCommand.export(getServerContext());
+      client.tableOperations().setProperty("expns.t1", 
"table.split.threshold", "200M");
+      opts.expectedFile = write(exportYaml);
+      opts.inputFile = write(exportYaml.replace("100M", "600M"));
+      cme = assertThrows(ConcurrentModificationException.class,
+          () -> ImportConfigCommand.load(getServerContext(),
+              new ByteArrayInputStream("".getBytes(UTF_8)), opts, 
precheckExpected));
+      assertEquals(
+          "Properties in scope:TABLE name:expns.t1 do not match the expected 
values. To diagnose, export current config to a new file and diff with expected 
file.",
+          cme.getMessage());
+      assertEquals(Map.of("table.split.threshold", "200M"),
+          client.tableOperations().getTableProperties("expns.t1"));
+      // correct expected file
+      opts.expectedFile = write(exportYaml.replace("100M", "200M"));
+      ImportConfigCommand.load(getServerContext(), new 
ByteArrayInputStream("".getBytes(UTF_8)),
+          opts, precheckExpected);
+      Wait.waitFor(() -> Map.of("table.split.threshold", "600M")
+          .equals(client.tableOperations().getTableProperties("expns.t1")));
+
+      // check case where expected file is missing a section
+      exportYaml = ExportConfigCommand.export(getServerContext());
+      client.tableOperations().create("expns.t2", new 
NewTableConfiguration().withoutDefaults());
+      // the export yaml does not contain the new table
+      var exportYaml2 = ExportConfigCommand.export(getServerContext());
+      opts.expectedFile = write(exportYaml);
+      opts.inputFile = write(exportYaml2.replace("600M", "123M"));
+      var iae = assertThrows(IllegalArgumentException.class,
+          () -> ImportConfigCommand.load(getServerContext(),
+              new ByteArrayInputStream("".getBytes(UTF_8)), opts, 
precheckExpected));
+      assertEquals(
+          "Scope+name present in input but not present in expected file, 
scope:TABLE name:expns.t2",
+          iae.getMessage());
+    }
+  }
+
   @Test
   public void testOffline() throws Exception {
     // Ensures import/export can work when no accumulo server processes are 
running

Reply via email to