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

chungen0126 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new 20e6a1c0f60 HDDS-15986. Report ozone local startup failures (#10934)
20e6a1c0f60 is described below

commit 20e6a1c0f6039248373efd3eb1d7f2afcf7f1535
Author: Chun-Hung Tseng <[email protected]>
AuthorDate: Tue Sep 15 21:42:32 2026 +0200

    HDDS-15986. Report ozone local startup failures (#10934)
---
 hadoop-ozone/tools/pom.xml                         |   4 +
 .../hadoop/ozone/local/LocalOzoneCluster.java      | 303 +++++++++++++++++----
 .../org/apache/hadoop/ozone/local/OzoneLocal.java  |  76 +++++-
 .../hadoop/ozone/local/TestLocalOzoneCluster.java  | 301 +++++++++++++++++++-
 .../apache/hadoop/ozone/local/TestOzoneLocal.java  | 126 ++++++++-
 5 files changed, 747 insertions(+), 63 deletions(-)

diff --git a/hadoop-ozone/tools/pom.xml b/hadoop-ozone/tools/pom.xml
index 8855e408414..8429437e5ad 100644
--- a/hadoop-ozone/tools/pom.xml
+++ b/hadoop-ozone/tools/pom.xml
@@ -67,6 +67,10 @@
       <groupId>org.apache.ozone</groupId>
       <artifactId>hdds-container-service</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.apache.ozone</groupId>
+      <artifactId>hdds-interface-admin</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.ozone</groupId>
       <artifactId>hdds-server-framework</artifactId>
diff --git 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneCluster.java
 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneCluster.java
index 89961e8243a..fdab4179bd8 100644
--- 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneCluster.java
+++ 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneCluster.java
@@ -92,10 +92,14 @@
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.hadoop.hdds.client.ReplicationFactor;
 import org.apache.hadoop.hdds.client.ReplicationType;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.conf.TimeDurationUtil;
+import 
org.apache.hadoop.hdds.protocol.proto.StorageContainerLocationProtocolProtos.SafeModeRuleStatusProto;
 import org.apache.hadoop.hdds.scm.proxy.SCMClientConfig;
 import org.apache.hadoop.hdds.scm.server.SCMStorageConfig;
 import org.apache.hadoop.hdds.scm.server.StorageContainerManager;
@@ -153,6 +157,7 @@ public final class LocalOzoneCluster implements 
LocalOzoneRuntime {
   private static final int LOCAL_RATIS_RPC_TIMEOUT_SECONDS = 1;
   private static final long SCM_CLIENT_MAX_RETRY_TIMEOUT_MILLIS = 30_000;
   private static final long READINESS_POLL_INTERVAL_MILLIS = 500;
+  private static final long READINESS_LOG_INTERVAL_NANOS = 
TimeUnit.SECONDS.toNanos(5);
 
   private static final int MAX_PORT = 65_535;
 
@@ -186,6 +191,11 @@ public final class LocalOzoneCluster implements 
LocalOzoneRuntime {
   // local ports, so an unbounded count would exhaust local ports; cap it.
   static final int MAX_DATANODES = 20;
 
+  private static final Set<String> SHIPPED_DEFAULT_RESOURCES = 
Collections.unmodifiableSet(
+      OzoneConfiguration.getConfigurationResourceFiles().stream()
+          .filter(resource -> resource.endsWith("-default.xml"))
+          .collect(Collectors.toSet()));
+
   private static final String[] NO_ARGS = new String[0];
 
   private final LocalOzoneClusterConfig config;
@@ -239,8 +249,10 @@ PreparedConfiguration prepareConfiguration() throws 
IOException {
       return preparedConfiguration;
     }
 
-    prepareStorageLayout();
-
+    // Every rejection of user input, validateStorageLayout() and the port 
checks included, runs before
+    // prepareStorageLayout(), which deletes the data dir in format mode 
ALWAYS.
+    requireSupportedDatanodeCount();
+    validateStorageLayout();
     OzoneConfiguration conf = new OzoneConfiguration(seedConfiguration);
     configureLocalDefaults(conf);
 
@@ -251,6 +263,8 @@ PreparedConfiguration prepareConfiguration() throws 
IOException {
     List<OzoneConfiguration> datanodeConfigurations =
         configureDatanodes(conf, persistedPorts, portAllocator);
 
+    prepareStorageLayout();
+    createServiceDirectories();
     persistedPorts.store();
     preparedConfiguration = new PreparedConfiguration(conf, scmPort, omPort,
         datanodeConfigurations);
@@ -314,8 +328,9 @@ public void close() throws IOException {
 
   private void stopServices() {
     try {
-      // Shutdown is best-effort so one failed service cannot leak the others.
-      IOUtils.closeQuietly(this::stopDatanodes, this::stopOm, this::stopScm);
+      // Logged rather than closeQuietly: stopServices() is also the start() 
rollback, where a silent
+      // failure hides leaked threads and ports.
+      IOUtils.close(LOG, this::stopDatanodes, this::stopOm, this::stopScm);
     } finally {
       restoreSameJvmMetricsMode();
     }
@@ -331,7 +346,7 @@ private void stopDatanodes() {
       });
     }
     datanodes.clear();
-    IOUtils.closeQuietly(stoppers);
+    IOUtils.close(LOG, stoppers);
   }
 
   private void stopOm() {
@@ -351,28 +366,25 @@ private void stopScm() {
     }
   }
 
-  private void configureLocalDefaults(OzoneConfiguration conf) {
+  private void configureLocalDefaults(OzoneConfiguration conf) throws 
IOException {
     conf.set(OZONE_METADATA_DIRS, metadataDir().toString());
     // SCM and OM share one configuration and JVM, so the Jetty base dir is a
     // single service-neutral location; Jetty keeps per-context temp dirs.
     conf.setIfUnset(OZONE_HTTP_BASEDIR, metadataDir() + SERVER_DIR);
-    conf.set(OZONE_REPLICATION, ReplicationFactor.ONE.name());
-    conf.set(OZONE_REPLICATION_TYPE, ReplicationType.STAND_ALONE.name());
-    conf.set(OZONE_SERVER_DEFAULT_REPLICATION_KEY, 
ReplicationFactor.ONE.name());
-    conf.set(OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY,
+    setLocalOverrideReplication(conf, OZONE_REPLICATION, 
ReplicationFactor.ONE);
+    setLocalOverride(conf, OZONE_REPLICATION_TYPE, 
ReplicationType.STAND_ALONE.name());
+    setLocalOverrideReplication(conf, OZONE_SERVER_DEFAULT_REPLICATION_KEY, 
ReplicationFactor.ONE);
+    setLocalOverride(conf, OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY,
         ReplicationType.STAND_ALONE.name());
-    conf.setBoolean(HDDS_CONTAINER_RATIS_ENABLED_KEY, false);
+    setLocalOverride(conf, HDDS_CONTAINER_RATIS_ENABLED_KEY, false);
     // A single-node local cluster can heartbeat aggressively; this speeds
-    // datanode registration and safe-mode exit. Use set(), not setIfUnset():
-    // ozone-default.xml supplies the 30s default that would otherwise defeat
-    // the override.
-    conf.set(HDDS_HEARTBEAT_INTERVAL, "1s");
-    conf.setBoolean(HDDS_SCM_SAFEMODE_PIPELINE_CREATION, false);
-    conf.setInt(HDDS_SCM_SAFEMODE_MIN_DATANODE,
-        Math.max(1, config.getDatanodes()));
-    conf.setTimeDuration(OZONE_OM_RATIS_MINIMUM_TIMEOUT_KEY,
-        LOCAL_RATIS_RPC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
-    conf.set(HDDS_SCM_WAIT_TIME_AFTER_SAFE_MODE_EXIT, "3s");
+    // datanode registration and safe-mode exit.
+    setLocalOverrideDuration(conf, HDDS_HEARTBEAT_INTERVAL, "1s");
+    setLocalOverride(conf, HDDS_SCM_SAFEMODE_PIPELINE_CREATION, false);
+    setLocalOverride(conf, HDDS_SCM_SAFEMODE_MIN_DATANODE, 
config.getDatanodes());
+    setLocalOverrideDuration(conf, OZONE_OM_RATIS_MINIMUM_TIMEOUT_KEY,
+        LOCAL_RATIS_RPC_TIMEOUT_SECONDS + "s");
+    setLocalOverrideDuration(conf, HDDS_SCM_WAIT_TIME_AFTER_SAFE_MODE_EXIT, 
"3s");
     conf.setIfUnset(OZONE_SCM_HA_RATIS_SERVER_RPC_FIRST_ELECTION_TIMEOUT,
         "1s");
 
@@ -381,6 +393,129 @@ private void configureLocalDefaults(OzoneConfiguration 
conf) {
     conf.setFromObject(scmClientConfig);
   }
 
+  /**
+   * Sets a value the local runtime requires and rejects a user-configured 
conflict. Set rather than
+   * {@code setIfUnset}: ozone-default.xml would otherwise win. The typed 
overloads compare through the
+   * accessor the services read the key with, so an equivalent spelling is not 
a conflict.
+   */
+  private void setLocalOverride(OzoneConfiguration conf, String key, String 
value)
+      throws IOException {
+    // Configuration#unset() keeps the key's source, so a null value is 
unconfigured, not a conflict.
+    // Trimmed because Hadoop keeps XML whitespace and the value is rewritten 
below anyway.
+    String configured = conf.get(key);
+    if (configured != null && !value.equals(configured.trim())) {
+      rejectUserConfigured(conf, key, value);
+    }
+    conf.set(key, value);
+  }
+
+  private void setLocalOverride(OzoneConfiguration conf, String key, boolean 
value)
+      throws IOException {
+    // The default is the negation so a value getBoolean() cannot parse counts 
as a conflict.
+    String configured = conf.get(key);
+    if (configured != null && conf.getBoolean(key, !value) != value) {
+      rejectUserConfigured(conf, key, String.valueOf(value));
+    }
+    conf.setBoolean(key, value);
+  }
+
+  private void setLocalOverride(OzoneConfiguration conf, String key, int value)
+      throws IOException {
+    String configured = conf.get(key);
+    if (configured != null && !matchesInt(conf, key, value)) {
+      rejectUserConfigured(conf, key, String.valueOf(value));
+    }
+    conf.setInt(key, value);
+  }
+
+  private void setLocalOverrideDuration(OzoneConfiguration conf, String key, 
String value)
+      throws IOException {
+    long requiredMillis = TimeDurationUtil.getTimeDurationHelper(key, value, 
TimeUnit.MILLISECONDS);
+    String configured = conf.get(key);
+    if (configured != null && !matchesDuration(conf, key, configured, 
requiredMillis)) {
+      rejectUserConfigured(conf, key, value);
+    }
+    conf.set(key, value);
+  }
+
+  private void setLocalOverrideReplication(OzoneConfiguration conf, String key,
+      ReplicationFactor value) throws IOException {
+    String configured = conf.get(key);
+    if (configured != null && parseReplicationFactor(configured) != value) {
+      rejectUserConfigured(conf, key, value.name());
+    }
+    conf.set(key, value.name());
+  }
+
+  private static void rejectUserConfigured(OzoneConfiguration conf, String 
key, String required)
+      throws IOException {
+    String source = userConfiguredSource(conf, key);
+    if (source == null) {
+      return;
+    }
+    throw new IOException("ozone local requires " + key + "=" + required
+        + ", but it is set to " + conf.get(key) + " (source: " + source
+        + "). Remove or change that setting.");
+  }
+
+  private static boolean matchesInt(OzoneConfiguration conf, String key, int 
value) {
+    try {
+      return conf.getInt(key, value) == value;
+    } catch (NumberFormatException unreadable) {
+      return false;
+    }
+  }
+
+  private static boolean matchesDuration(OzoneConfiguration conf, String key, 
String configured,
+      long requiredMillis) {
+    if (lacksTimeUnit(configured)) {
+      return false;
+    }
+    try {
+      return conf.getTimeDuration(key, requiredMillis, TimeUnit.MILLISECONDS) 
== requiredMillis;
+    } catch (NumberFormatException unreadable) {
+      return false;
+    }
+  }
+
+  /**
+   * {@link TimeDurationUtil#getDuration} reads a bare number in the caller's 
unit with only a warning,
+   * so "120" would mean 120 milliseconds. Every unit suffix it accepts ends 
in a letter.
+   */
+  static boolean lacksTimeUnit(String value) {
+    String trimmed = value.trim();
+    return !trimmed.isEmpty() && 
Character.isDigit(trimmed.charAt(trimmed.length() - 1));
+  }
+
+  /** Accepts both spellings {@link 
org.apache.hadoop.hdds.client.ReplicationConfig#parse} does, "1" and "ONE". */
+  private static ReplicationFactor parseReplicationFactor(String value) {
+    String trimmed = value.trim();
+    try {
+      return ReplicationFactor.valueOf(Integer.parseInt(trimmed));
+    } catch (IllegalArgumentException notNumeric) {
+      try {
+        return ReplicationFactor.valueOf(trimmed);
+      } catch (IllegalArgumentException notNamed) {
+        return null;
+      }
+    }
+  }
+
+  /**
+   * Source of the value the user chose for {@code key}, or null when its last 
source is a shipped
+   * {@code *-default.xml} classpath resource. The comparison is exact: 
Configuration records a classpath
+   * resource by its bare name, and {@link OzoneLocal#setConfigurationPath} 
absolutizes a {@code --conf}
+   * path so a file with the same name stays a user choice.
+   */
+  private static String userConfiguredSource(OzoneConfiguration conf, String 
key) {
+    String[] sources = conf.getPropertySources(key);
+    if (sources == null || sources.length == 0) {
+      return null;
+    }
+    String source = sources[sources.length - 1];
+    return SHIPPED_DEFAULT_RESOURCES.contains(source) ? null : source;
+  }
+
   private int configureScm(OzoneConfiguration conf,
       PersistedPortState persistedPorts, PortAllocator portAllocator)
       throws IOException {
@@ -427,10 +562,9 @@ private int configureScm(OzoneConfiguration conf,
     return scmClientPort;
   }
 
-  private void configureScmStorage(OzoneConfiguration conf) throws IOException 
{
+  private void configureScmStorage(OzoneConfiguration conf) {
     Path scmDir = config.getDataDir().resolve(SCM_DIR_NAME);
     Path scmMetadataDir = scmDir.resolve(OZONE_METADATA_DIR_NAME);
-    Files.createDirectories(scmDir.resolve(DATA_DIR_NAME));
 
     conf.setIfUnset(OZONE_SCM_DB_DIRS,
         scmDir.resolve(DATA_DIR_NAME).toString());
@@ -463,10 +597,9 @@ private int configureOm(OzoneConfiguration conf,
     return omRpcPort;
   }
 
-  private void configureOmStorage(OzoneConfiguration conf) throws IOException {
+  private void configureOmStorage(OzoneConfiguration conf) {
     Path omDir = config.getDataDir().resolve(OM_DIR_NAME);
     Path omMetadataDir = omDir.resolve(OZONE_METADATA_DIR_NAME);
-    Files.createDirectories(omDir.resolve(DATA_DIR_NAME));
 
     conf.setIfUnset(OZONE_OM_DB_DIRS, omDir.resolve(DATA_DIR_NAME).toString());
     conf.setIfUnset(OZONE_OM_RATIS_STORAGE_DIR,
@@ -480,13 +613,6 @@ private void configureOmStorage(OzoneConfiguration conf) 
throws IOException {
   private List<OzoneConfiguration> configureDatanodes(OzoneConfiguration conf,
       PersistedPortState persistedPorts, PortAllocator portAllocator)
       throws IOException {
-    int datanodeCount = config.getDatanodes();
-    if (datanodeCount > MAX_DATANODES) {
-      throw new IOException("Datanode count " + datanodeCount
-          + " exceeds the local maximum of " + MAX_DATANODES
-          + "; each datanode reserves " + DATANODE_PORT_KEY_SUFFIXES.length
-          + " local ports.");
-    }
     List<OzoneConfiguration> datanodeConfigurations =
         new ArrayList<>(config.getDatanodes());
     for (int index = 0; index < config.getDatanodes(); index++) {
@@ -534,13 +660,10 @@ private OzoneConfiguration 
configureDatanode(OzoneConfiguration conf,
     return dnConf;
   }
 
-  private void configureDatanodeStorage(OzoneConfiguration dnConf, int index)
-      throws IOException {
+  private void configureDatanodeStorage(OzoneConfiguration dnConf, int index) {
     Path datanodeDir = config.getDataDir()
         .resolve(DATANODE_DIR_PREFIX + index);
     Path datanodeMetadataDir = datanodeDir.resolve(OZONE_METADATA_DIR_NAME);
-    Files.createDirectories(datanodeMetadataDir);
-    Files.createDirectories(datanodeDir.resolve(DATA_DIR_NAME));
 
     dnConf.set(OZONE_METADATA_DIRS, datanodeMetadataDir.toString());
     // Each datanode gets its own Jetty base dir so same-JVM HTTP servers do
@@ -643,30 +766,66 @@ private void startDatanodes(List<OzoneConfiguration> 
datanodeConfigurations) {
   }
 
   private void waitForClusterReadiness(Duration timeout) throws Exception {
-    long deadlineNanos = System.nanoTime() + timeout.toNanos();
+    waitForReadiness(this::clusterReadinessBlocker, "Ozone cluster", timeout);
+  }
+
+  /** {@code blocker} returns why {@code subject} is not ready yet, or null 
once it is. */
+  static void waitForReadiness(Supplier<String> blocker, String subject, 
Duration timeout)
+      throws InterruptedException, TimeoutException {
+    long startNanos = System.nanoTime();
+    long nextLogNanos = startNanos + READINESS_LOG_INTERVAL_NANOS;
     while (true) {
-      if (isClusterReady()) {
+      String reason = blocker.get();
+      if (reason == null) {
         return;
       }
-      if (System.nanoTime() >= deadlineNanos) {
-        throw new TimeoutException("Timed out waiting " + timeout
-            + " for the local Ozone cluster to become ready.");
+      long now = System.nanoTime();
+      // Compared as a Duration because timeout.toNanos() overflows for very 
long timeouts.
+      if (Duration.ofNanos(now - startNanos).compareTo(timeout) >= 0) {
+        throw new TimeoutException("Timed out waiting " + timeout + " for the 
local " + subject
+            + " to become ready: " + reason + ".");
+      }
+      if (now >= nextLogNanos) {
+        LOG.info("Waiting for the local {} to become ready: {}.", subject, 
reason);
+        nextLogNanos = now + READINESS_LOG_INTERVAL_NANOS;
       }
       Thread.sleep(READINESS_POLL_INTERVAL_MILLIS);
     }
   }
 
-  private boolean isClusterReady() {
-    if (!scm.checkLeader() || !om.isLeaderReady()) {
-      return false;
+  private String clusterReadinessBlocker() {
+    if (!scm.checkLeader()) {
+      return "SCM has no Ratis leader yet";
+    }
+    if (!om.isLeaderReady()) {
+      return "OM is not leader-ready yet";
     }
-    if (config.getDatanodes() == 0) {
-      return true;
+    int registered = scm.getScmNodeManager().getAllNodeCount();
+    if (registered < config.getDatanodes()) {
+      return "only " + registered + " of " + config.getDatanodes()
+          + " datanodes have registered with SCM";
     }
-    // The cluster is usable once every datanode has registered with SCM and
-    // SCM has left safe mode.
-    return scm.getScmNodeManager().getAllNodes().size() >= 
config.getDatanodes()
-        && !scm.isInSafeMode();
+    // SCM refuses block allocation until it leaves safe mode 
(BlockManagerImpl#allocateBlock).
+    if (scm.isInSafeMode()) {
+      return "SCM is still in safe mode ("
+          + formatSafeModeRuleBlocker(scm.getRuleStatus()) + ")";
+    }
+    return null;
+  }
+
+  static String formatSafeModeRuleBlocker(List<SafeModeRuleStatusProto> rules) 
{
+    if (rules.isEmpty()) {
+      return "safe-mode rule status is not available yet";
+    }
+    String unmetRules = joinSafeModeRules(rules.stream().filter(rule -> 
!rule.getValidate()));
+    return unmetRules.isEmpty()
+        ? "all rules currently report satisfied: " + 
joinSafeModeRules(rules.stream())
+        : unmetRules;
+  }
+
+  private static String joinSafeModeRules(Stream<SafeModeRuleStatusProto> 
rules) {
+    return rules.map(rule -> rule.getRuleName() + ": " + rule.getStatusText())
+        .collect(Collectors.joining("; "));
   }
 
   private void enableSameJvmMetricsMode() {
@@ -684,20 +843,39 @@ private void restoreSameJvmMetricsMode() {
     }
   }
 
-  private void prepareStorageLayout() throws IOException {
+  private void requireSupportedDatanodeCount() throws IOException {
+    int datanodeCount = config.getDatanodes();
+    if (datanodeCount < 1) {
+      throw new IOException("Datanode count " + datanodeCount + " is below the 
local minimum of 1.");
+    }
+    if (datanodeCount > MAX_DATANODES) {
+      throw new IOException("Datanode count " + datanodeCount
+          + " exceeds the local maximum of " + MAX_DATANODES
+          + "; each datanode reserves " + DATANODE_PORT_KEY_SUFFIXES.length
+          + " local ports.");
+    }
+  }
+
+  private void validateStorageLayout() throws IOException {
     Path dataDir = config.getDataDir();
     if (Files.exists(dataDir) && !Files.isDirectory(dataDir)) {
       throw new IOException("Local Ozone data dir " + dataDir
           + " is not a directory.");
     }
+    if (config.getFormatMode() == LocalOzoneClusterConfig.FormatMode.NEVER) {
+      requireExistingLayout();
+    }
+  }
 
+  private void prepareStorageLayout() throws IOException {
     switch (config.getFormatMode()) {
     case ALWAYS:
-      deleteDirectory(dataDir);
+      LOG.info("Removing local Ozone data dir {} (format mode ALWAYS).", 
config.getDataDir());
+      deleteDirectory(config.getDataDir());
       createBaseLayout();
       break;
     case NEVER:
-      requireExistingLayout();
+      // validateStorageLayout() already required the existing layout.
       break;
     case IF_NEEDED:
       createBaseLayout();
@@ -708,6 +886,17 @@ private void prepareStorageLayout() throws IOException {
     }
   }
 
+  private void createServiceDirectories() throws IOException {
+    Path dataDir = config.getDataDir();
+    
Files.createDirectories(dataDir.resolve(SCM_DIR_NAME).resolve(DATA_DIR_NAME));
+    
Files.createDirectories(dataDir.resolve(OM_DIR_NAME).resolve(DATA_DIR_NAME));
+    for (int index = 0; index < config.getDatanodes(); index++) {
+      Path datanodeDir = dataDir.resolve(DATANODE_DIR_PREFIX + index);
+      Files.createDirectories(datanodeDir.resolve(OZONE_METADATA_DIR_NAME));
+      Files.createDirectories(datanodeDir.resolve(DATA_DIR_NAME));
+    }
+  }
+
   private void createBaseLayout() throws IOException {
     Files.createDirectories(config.getDataDir());
     Files.createDirectories(metadataDir());
@@ -734,6 +923,10 @@ private void requireExistingLayout() throws IOException {
   }
 
   private PersistedPortState loadPersistedPortState() throws IOException {
+    if (config.getFormatMode() == LocalOzoneClusterConfig.FormatMode.ALWAYS) {
+      // Format mode ALWAYS deletes the data dir in prepareStorageLayout(), so 
the persisted ports would be stale.
+      return PersistedPortState.empty(portStateFile());
+    }
     PersistedPortState persistedPorts =
         PersistedPortState.load(portStateFile());
     if (config.getFormatMode() == LocalOzoneClusterConfig.FormatMode.NEVER) {
@@ -888,6 +1081,10 @@ private PersistedPortState(Path path) {
       this.path = path;
     }
 
+    static PersistedPortState empty(Path path) {
+      return new PersistedPortState(path);
+    }
+
     static PersistedPortState load(Path path) throws IOException {
       PersistedPortState state = new PersistedPortState(path);
       if (!Files.exists(path)) {
diff --git 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/OzoneLocal.java
 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/OzoneLocal.java
index 0d5e6ec1734..79b50efc729 100644
--- 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/OzoneLocal.java
+++ 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/OzoneLocal.java
@@ -19,6 +19,7 @@
 
 import java.io.PrintWriter;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.time.Duration;
 import java.time.format.DateTimeParseException;
 import java.util.concurrent.Callable;
@@ -109,10 +110,57 @@ public OzoneLocal() {
     super(factory);
   }
 
+  @Override
+  public void setConfigurationPath(String configPath) {
+    // Configuration records a classpath resource by its bare name and 
LocalOzoneCluster#userConfiguredSource
+    // compares names exactly, so a scheme-less path is absolutized: 
./ozone-default.xml must stay a user
+    // choice. A scheme (file:, hdfs:) cannot collide and java.nio would 
mangle it into a literal path, so
+    // it passes through. fs.Path does the scheme check because it also 
rejects an empty value at parse.
+    if (new org.apache.hadoop.fs.Path(configPath).toUri().getScheme() == null) 
{
+      configPath = 
Paths.get(configPath).toAbsolutePath().normalize().toString();
+    }
+    super.setConfigurationPath(configPath);
+  }
+
+  @Override
+  public void printError(Throwable error) {
+    if (!(error instanceof StartupFailureException)) {
+      super.printError(error);
+      return;
+    }
+
+    super.printError(error.getCause());
+    String hint = startupFailureHint(LOG.isInfoEnabled(), isVerbose());
+    if (hint != null) {
+      err().println(hint);
+    }
+  }
+
+  static String startupFailureHint(boolean infoEnabled, boolean verbose) {
+    if (infoEnabled && verbose) {
+      return null;
+    }
+    if (!infoEnabled && !verbose) {
+      return "For more detail, re-run with `ozone --loglevel INFO local run` 
for service logs,"
+          + " or add --verbose for the full stack trace.";
+    }
+    if (!infoEnabled) {
+      return "For service logs, re-run with `ozone --loglevel INFO local 
run`.";
+    }
+    return "For the full stack trace, add --verbose.";
+  }
+
   public static void main(String[] args) {
     new OzoneLocal().run(args);
   }
 
+  private static final class StartupFailureException extends Exception {
+
+    private StartupFailureException(Throwable cause) {
+      super(cause);
+    }
+  }
+
   @Command(name = "run",
       description = "Start SCM, OM, and datanodes in one local process")
   static class RunCommand extends AbstractSubcommand implements Callable<Void> 
{
@@ -197,13 +245,22 @@ static class RunCommand extends AbstractSubcommand 
implements Callable<Void> {
     public Void call() throws Exception {
       LocalOzoneClusterConfig config = resolveConfig();
       try (LocalOzoneRuntime runtime = createRuntime(config, getOzoneConf())) {
-        runtime.start();
+        start(runtime);
         printSummary(runtime, config);
         awaitShutdown(runtime);
       }
       return null;
     }
 
+    /** Wraps startup failures so {@link OzoneLocal#printError} can print the 
cause and then the hint. */
+    private void start(LocalOzoneRuntime runtime) throws Exception {
+      try {
+        runtime.start();
+      } catch (Exception ex) {
+        throw new StartupFailureException(ex);
+      }
+    }
+
     LocalOzoneRuntime createRuntime(LocalOzoneClusterConfig config, 
OzoneConfiguration seedConfiguration) {
       return new LocalOzoneCluster(config, seedConfiguration);
     }
@@ -293,8 +350,9 @@ public LocalOzoneClusterConfig.FormatMode convert(String 
value) {
         try {
           return LocalOzoneClusterConfig.FormatMode.fromString(value);
         } catch (IllegalArgumentException ex) {
-          throw new CommandLine.TypeConversionException(
-              "Expected one of: if-needed, always, never.");
+          // picocli's wrapper names the option but not the value, which may 
come from OZONE_LOCAL_FORMAT.
+          throw new CommandLine.TypeConversionException("Invalid format mode 
'" + value
+              + "'. Expected one of: if-needed, always, never.");
         }
       }
     }
@@ -304,19 +362,25 @@ private static final class DurationConverter
 
       @Override
       public Duration convert(String value) {
+        String trimmed = value.trim();
         try {
-          return Duration.parse(value.trim());
+          return Duration.parse(trimmed);
         } catch (DateTimeParseException ignored) {
-          return parseHadoopStyleDuration(value);
+          return parseHadoopStyleDuration(trimmed);
         }
       }
 
       private static Duration parseHadoopStyleDuration(String value) {
+        if (LocalOzoneCluster.lacksTimeUnit(value)) {
+          throw new CommandLine.TypeConversionException("Missing time unit in 
'" + value
+              + "'. " + durationMessage());
+        }
         try {
           return TimeDurationUtil.getDuration("--startup-timeout", value,
               TimeUnit.MILLISECONDS);
         } catch (RuntimeException ex) {
-          throw new CommandLine.TypeConversionException(durationMessage());
+          throw new CommandLine.TypeConversionException("Invalid duration '" + 
value
+              + "'. " + durationMessage());
         }
       }
 
diff --git 
a/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneCluster.java
 
b/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneCluster.java
index 1f109c6de42..c0d2e19aea5 100644
--- 
a/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneCluster.java
+++ 
b/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneCluster.java
@@ -18,8 +18,10 @@
 package org.apache.hadoop.ozone.local;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.hadoop.hdds.HddsConfigKeys.HDDS_HEARTBEAT_INTERVAL;
 import static 
org.apache.hadoop.hdds.HddsConfigKeys.HDDS_SCM_SAFEMODE_MIN_DATANODE;
 import static 
org.apache.hadoop.hdds.HddsConfigKeys.HDDS_SCM_SAFEMODE_PIPELINE_CREATION;
+import static 
org.apache.hadoop.hdds.HddsConfigKeys.HDDS_SCM_WAIT_TIME_AFTER_SAFE_MODE_EXIT;
 import static 
org.apache.hadoop.hdds.scm.ScmConfigKeys.HDDS_CONTAINER_RATIS_ENABLED_KEY;
 import static org.apache.hadoop.hdds.scm.ScmConfigKeys.HDDS_DATANODE_DIR_KEY;
 import static 
org.apache.hadoop.hdds.scm.ScmConfigKeys.OZONE_SCM_CLIENT_ADDRESS_KEY;
@@ -29,6 +31,7 @@
 import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_REPLICATION;
 import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_REPLICATION_TYPE;
 import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_OM_ADDRESS_KEY;
+import static 
org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_OM_RATIS_MINIMUM_TIMEOUT_KEY;
 import static 
org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_KEY;
 import static 
org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -42,10 +45,16 @@
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Properties;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.hadoop.hdds.client.ReplicationFactor;
 import org.apache.hadoop.hdds.client.ReplicationType;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import 
org.apache.hadoop.hdds.protocol.proto.StorageContainerLocationProtocolProtos.SafeModeRuleStatusProto;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
@@ -349,6 +358,255 @@ void prepareConfigurationRejectsTooManyDatanodes() throws 
Exception {
         + "; each datanode reserves 8 local ports.", error.getMessage());
   }
 
+  @Test
+  void zeroDatanodesIsRejected() throws Exception {
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(
+            tempDir.resolve("local-ozone"))
+        .setDatanodes(0)
+        .build();
+
+    IOException error = assertPrepareFails(config);
+
+    assertMessageContains(error, "Datanode count 0");
+  }
+
+  /** Configuration#unset() keeps the source, so the key must read as 
unconfigured, not as a conflict. */
+  @Test
+  void keyUnsetAfterBeingConfiguredIsNotRejected() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_REPLICATION, ReplicationFactor.THREE.name(), 
"test-ozone-site.xml");
+    seed.unset(OZONE_REPLICATION);
+
+    assertEquals(ReplicationFactor.ONE.name(), 
prepared(seed).get(OZONE_REPLICATION));
+  }
+
+  @Test
+  void configuredMetadataDirIsReplacedByTheLocalDataDir() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_METADATA_DIRS, "/real/cluster/metadata", 
"test-ozone-site.xml");
+
+    assertEquals(metadataDir(tempDir.resolve("local-ozone")).toString(),
+        prepared(seed).get(OZONE_METADATA_DIRS));
+  }
+
+  @Test
+  void defaultsFileNamedByPathCountsAsUserConfig() {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_REPLICATION, ReplicationFactor.THREE.name(), 
"/home/me/ozone-default.xml");
+
+    IOException error = assertPrepareFails(seed);
+
+    assertMessageContains(error, OZONE_REPLICATION);
+  }
+
+  @Test
+  void tooManyDatanodesIsRejectedBeforeFormatDeletesDataDir() throws Exception 
{
+    Path dataDir = tempDir.resolve("local-ozone");
+    Path marker = writeMarker(dataDir, "keep me");
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(dataDir)
+        .setFormatMode(LocalOzoneClusterConfig.FormatMode.ALWAYS)
+        .setDatanodes(LocalOzoneCluster.MAX_DATANODES + 1)
+        .build();
+
+    assertPrepareFails(config);
+
+    assertTrue(Files.exists(marker),
+        "format ALWAYS must not delete the data dir for a run that cannot 
start");
+  }
+
+  @Test
+  void conflictingUserConfigIsRejected() {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_REPLICATION, ReplicationFactor.THREE.name(), 
"test-ozone-site.xml");
+
+    IOException error = assertPrepareFails(seed);
+
+    assertMessageContains(error, OZONE_REPLICATION);
+    assertMessageContains(error, ReplicationFactor.ONE.name());
+    assertMessageContains(error, ReplicationFactor.THREE.name());
+    assertMessageContains(error, "test-ozone-site.xml");
+  }
+
+  @Test
+  void userConfigMatchingTheLocalRequirementIsAccepted() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_REPLICATION, ReplicationFactor.ONE.name(), 
"test-ozone-site.xml");
+
+    assertEquals(ReplicationFactor.ONE.name(), 
prepared(seed).get(OZONE_REPLICATION));
+  }
+
+  @Test
+  void conflictingUserConfigIsRejectedBeforeFormatDeletesDataDir() throws 
Exception {
+    Path dataDir = tempDir.resolve("local-ozone");
+    Path marker = writeMarker(dataDir, "keep me");
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_REPLICATION, ReplicationFactor.THREE.name(), 
"test-ozone-site.xml");
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(dataDir)
+        .setFormatMode(LocalOzoneClusterConfig.FormatMode.ALWAYS)
+        .build();
+
+    assertPrepareFails(config, seed);
+
+    assertTrue(Files.exists(marker),
+        "format ALWAYS must not delete the data dir for a run that cannot 
start");
+  }
+
+  @Test
+  void duplicateConfiguredPortsAreRejectedBeforeFormatDeletesDataDir() throws 
Exception {
+    Path dataDir = tempDir.resolve("local-ozone");
+    Path marker = writeMarker(dataDir, "keep me");
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(dataDir)
+        .setFormatMode(LocalOzoneClusterConfig.FormatMode.ALWAYS)
+        .setScmPort(9860)
+        .setOmPort(9860)
+        .build();
+
+    IOException error = assertPrepareFails(config);
+
+    assertMessageContains(error, "more than once");
+    assertTrue(Files.exists(marker),
+        "format ALWAYS must not delete the data dir for a run that cannot 
start");
+  }
+
+  /** Hadoop keeps XML whitespace, so padding around the required value is not 
a conflict. */
+  @Test
+  void paddedValueMatchingTheLocalRequirementIsAccepted() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_REPLICATION_TYPE, "\n  STAND_ALONE\n", 
"test-ozone-site.xml");
+
+    assertEquals(ReplicationType.STAND_ALONE.name(), 
prepared(seed).get(OZONE_REPLICATION_TYPE));
+  }
+
+  /** Duration#toNanos throws ArithmeticException for this timeout, so the 
wait must not call it. */
+  @Test
+  void readinessAcceptsTimeoutBeyondNanoTimeRange() throws Exception {
+    LocalOzoneCluster.waitForReadiness(() -> null, "Ozone cluster",
+        Duration.ofDays(999_999_999L));
+  }
+
+  @Test
+  void equivalentDurationSpellingIsAccepted() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(HDDS_HEARTBEAT_INTERVAL, "1000ms", "test-ozone-site.xml");
+    seed.set(OZONE_OM_RATIS_MINIMUM_TIMEOUT_KEY, "1000ms", 
"test-ozone-site.xml");
+    seed.set(HDDS_SCM_WAIT_TIME_AFTER_SAFE_MODE_EXIT, "3000ms", 
"test-ozone-site.xml");
+
+    OzoneConfiguration conf = prepared(seed);
+
+    assertEquals("1s", conf.get(HDDS_HEARTBEAT_INTERVAL));
+    assertEquals("1s", conf.get(OZONE_OM_RATIS_MINIMUM_TIMEOUT_KEY));
+    assertEquals("3s", conf.get(HDDS_SCM_WAIT_TIME_AFTER_SAFE_MODE_EXIT));
+  }
+
+  @Test
+  void unitlessConfigurationDurationsAreRejected() {
+    assertUnitlessDurationRejected(HDDS_HEARTBEAT_INTERVAL, "1000");
+    assertUnitlessDurationRejected(OZONE_OM_RATIS_MINIMUM_TIMEOUT_KEY, "1");
+    assertUnitlessDurationRejected(HDDS_SCM_WAIT_TIME_AFTER_SAFE_MODE_EXIT, 
"3000");
+  }
+
+  @Test
+  void equivalentBooleanSpellingIsAccepted() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(HDDS_CONTAINER_RATIS_ENABLED_KEY, "FALSE", "test-ozone-site.xml");
+
+    assertFalse(prepared(seed).getBoolean(HDDS_CONTAINER_RATIS_ENABLED_KEY, 
true));
+  }
+
+  @Test
+  void numericReplicationIsAccepted() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_SERVER_DEFAULT_REPLICATION_KEY, "1", "test-ozone-site.xml");
+
+    assertEquals(ReplicationFactor.ONE.name(),
+        prepared(seed).get(OZONE_SERVER_DEFAULT_REPLICATION_KEY));
+  }
+
+  @Test
+  void conflictingDurationIsRejected() {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(HDDS_HEARTBEAT_INTERVAL, "30s", "test-ozone-site.xml");
+
+    IOException error = assertPrepareFails(seed);
+
+    assertMessageContains(error, HDDS_HEARTBEAT_INTERVAL);
+    assertMessageContains(error, "30s");
+  }
+
+  @Test
+  void unparseableValueIsRejected() {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(HDDS_HEARTBEAT_INTERVAL, "banana", "test-ozone-site.xml");
+
+    IOException error = assertPrepareFails(seed);
+
+    assertMessageContains(error, HDDS_HEARTBEAT_INTERVAL);
+    assertMessageContains(error, "banana");
+  }
+
+  /**
+   * Regression guard: ozone-default.xml ships hdds.heartbeat.interval=30s, so 
a shipped default read as a
+   * user choice would reject every run.
+   */
+  @Test
+  void shippedDefaultIsNotTreatedAsUserConfig() throws Exception {
+    assertEquals("1s", prepared(new 
OzoneConfiguration()).get(HDDS_HEARTBEAT_INTERVAL));
+    assertNotEquals("1s", new 
OzoneConfiguration().get(HDDS_HEARTBEAT_INTERVAL),
+        "the shipped default must differ from the local value, or this guards 
nothing");
+  }
+
+  @Test
+  void generatedShippedDefaultIsNotTreatedAsUserConfig() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_REPLICATION, ReplicationFactor.THREE.name(), 
"hdds-server-scm-default.xml");
+
+    assertEquals(ReplicationFactor.ONE.name(), 
prepared(seed).get(OZONE_REPLICATION));
+  }
+
+  @Test
+  void safeModeRuleBlockerListsOnlyUnmetRules() {
+    String blocker = LocalOzoneCluster.formatSafeModeRuleBlocker(Arrays.asList(
+        safeModeRule("registered datanodes", true, "registered"),
+        safeModeRule("healthy containers", false, "1 of 3 reported")));
+
+    assertEquals("healthy containers: 1 of 3 reported", blocker);
+  }
+
+  @Test
+  void safeModeRuleBlockerFallsBackToSatisfiedRuleStatuses() {
+    String blocker = 
LocalOzoneCluster.formatSafeModeRuleBlocker(Collections.singletonList(
+        safeModeRule("registered datanodes", true, "registered")));
+
+    assertEquals("all rules currently report satisfied: registered datanodes: 
registered", blocker);
+  }
+
+  @Test
+  void safeModeRuleBlockerExplainsUnavailableRuleStatus() {
+    assertEquals("safe-mode rule status is not available yet",
+        LocalOzoneCluster.formatSafeModeRuleBlocker(Collections.emptyList()));
+  }
+
+  @Test
+  void readinessTimeoutNamesTheUnmetCondition() {
+    TimeoutException error = assertThrows(TimeoutException.class,
+        () -> LocalOzoneCluster.waitForReadiness(() -> "only 1 of 3 datanodes 
have registered",
+            "Ozone cluster", Duration.ZERO));
+
+    assertMessageContains(error, "only 1 of 3 datanodes have registered");
+    assertMessageContains(error, "Ozone cluster");
+  }
+
+  @Test
+  void readinessReturnsOnceBlockerReportsReady() throws Exception {
+    AtomicInteger attempts = new AtomicInteger();
+
+    LocalOzoneCluster.waitForReadiness(
+        () -> attempts.incrementAndGet() < 2 ? "not yet" : null, "Ozone 
cluster",
+        Duration.ofSeconds(30));
+
+    assertEquals(2, attempts.get());
+  }
+
   @Test
   void persistedPortFileContainsDatanodePorts() throws Exception {
     Path dataDir = tempDir.resolve("local-ozone");
@@ -425,15 +683,46 @@ private LocalOzoneCluster 
newCluster(LocalOzoneClusterConfig config) {
     return new LocalOzoneCluster(config, new OzoneConfiguration());
   }
 
+  private OzoneConfiguration prepared(OzoneConfiguration seed) throws 
IOException {
+    LocalOzoneClusterConfig config = defaultConfig();
+    try (LocalOzoneCluster cluster = new LocalOzoneCluster(config, seed)) {
+      return cluster.prepareConfiguration().getConfiguration();
+    }
+  }
+
+  private LocalOzoneClusterConfig defaultConfig() {
+    return 
LocalOzoneClusterConfig.builder(tempDir.resolve("local-ozone")).build();
+  }
+
   private IOException assertPrepareFails(LocalOzoneClusterConfig config) {
+    return assertPrepareFails(config, new OzoneConfiguration());
+  }
+
+  private IOException assertPrepareFails(OzoneConfiguration seed) {
+    return assertPrepareFails(defaultConfig(), seed);
+  }
+
+  private IOException assertPrepareFails(LocalOzoneClusterConfig config,
+      OzoneConfiguration seed) {
     return assertThrows(IOException.class, () -> {
-      try (LocalOzoneCluster cluster = newCluster(config)) {
+      try (LocalOzoneCluster cluster = new LocalOzoneCluster(config, seed)) {
         cluster.prepareConfiguration();
       }
     });
   }
 
-  private void assertMessageContains(IOException error, String expectedText) {
+  private void assertUnitlessDurationRejected(String key, String value) {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(key, value, "test-ozone-site.xml");
+
+    IOException error = assertPrepareFails(seed);
+
+    assertMessageContains(error, key);
+    assertMessageContains(error, value);
+    assertMessageContains(error, "test-ozone-site.xml");
+  }
+
+  private void assertMessageContains(Exception error, String expectedText) {
     assertTrue(error.getMessage().contains(expectedText), error.getMessage());
   }
 
@@ -456,6 +745,14 @@ private void assertPositivePort(Properties properties, 
String key) {
     assertTrue(Integer.parseInt(properties.getProperty(key)) > 0, key);
   }
 
+  private static SafeModeRuleStatusProto safeModeRule(String name, boolean 
validated, String status) {
+    return SafeModeRuleStatusProto.newBuilder()
+        .setRuleName(name)
+        .setValidate(validated)
+        .setStatusText(status)
+        .build();
+  }
+
   private Path metadataDir(Path dataDir) {
     return dataDir.resolve(METADATA_DIR_NAME);
   }
diff --git 
a/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestOzoneLocal.java
 
b/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestOzoneLocal.java
index 0f8686e91a2..c91c30d563a 100644
--- 
a/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestOzoneLocal.java
+++ 
b/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestOzoneLocal.java
@@ -18,20 +18,28 @@
 package org.apache.hadoop.ozone.local;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_REPLICATION;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 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.assertTimeoutPreemptively;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.time.Duration;
+import org.apache.hadoop.hdds.cli.GenericCli;
 import org.apache.hadoop.hdds.conf.OzoneConfiguration;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import picocli.CommandLine;
 import picocli.CommandLine.Command;
 import picocli.CommandLine.IDefaultValueProvider;
@@ -108,6 +116,7 @@ void runCommandStartsRuntimeAndPrintsStartupSummary() 
throws Exception {
     assertTrue(text.contains("Local Ozone is running from"), text);
     assertTrue(text.contains("SCM RPC: localhost:9860"), text);
     assertTrue(text.contains("OM RPC: localhost:9862"), text);
+    assertFalse(text.contains("Datanodes:"), text);
     assertTrue(text.contains("Press Ctrl+C to stop."), text);
   }
 
@@ -123,6 +132,107 @@ void runCommandClosesRuntimeWhenStartupFails() {
     assertTrue(runtime.closed);
   }
 
+  @Test
+  void runCommandPreservesStartupFailureAsTheCause() throws Exception {
+    ByteArrayOutputStream err = new ByteArrayOutputStream();
+    StubRuntime runtime = new StubRuntime("localhost", 9860, 9862);
+    runtime.failStart = true;
+    TestableRunCommand command = new TestableRunCommand(runtime);
+    CommandLine commandLine = new CommandLine(command);
+    commandLine.setErr(new PrintWriter(new OutputStreamWriter(err, UTF_8), 
true));
+    commandLine.parseArgs();
+
+    Exception error = assertThrows(Exception.class, command::call);
+
+    assertTrue(error.getCause() instanceof IllegalStateException, 
error.toString());
+    assertEquals("startup failed", error.getCause().getMessage());
+    assertEquals("", err.toString(UTF_8.name()));
+    assertTrue(runtime.closed);
+  }
+
+  /** Preemptive timeout: execute() otherwise blocks in awaitShutdown() until 
a JVM shutdown hook fires. */
+  @Test
+  void conflictingConfigReachesStderrThroughGenericCli(@TempDir Path dataDir) 
throws Exception {
+    ByteArrayOutputStream err = new ByteArrayOutputStream();
+    OzoneLocal ozoneLocal = new OzoneLocal();
+    ozoneLocal.getCmd().setErr(new PrintWriter(new OutputStreamWriter(err, 
UTF_8), true));
+
+    int exitCode = assertTimeoutPreemptively(Duration.ofSeconds(30),
+        () -> ozoneLocal.execute(new String[] {"-D", OZONE_REPLICATION + 
"=THREE",
+            "run", "--data-dir", dataDir.toString()}));
+
+    assertEquals(GenericCli.EXECUTION_ERROR_EXIT_CODE, exitCode);
+    String[] lines = err.toString(UTF_8.name()).trim().split("\n");
+    assertEquals(2, lines.length, err.toString(UTF_8.name()));
+    assertTrue(lines[0].contains(OZONE_REPLICATION), lines[0]);
+    assertTrue(lines[0].contains("THREE"), lines[0]);
+    assertTrue(lines[1].contains("--verbose"), lines[1]);
+  }
+
+  @Test
+  void relativeDefaultNamedConfigFileCountsAsUserConfig(@TempDir Path tempDir) 
throws Exception {
+    Path customConfig = tempDir.resolve("ozone-default.xml").toAbsolutePath();
+    Files.write(customConfig, ("<configuration><property><name>" + 
OZONE_REPLICATION
+        + 
"</name><value>THREE</value></property></configuration>").getBytes(UTF_8));
+    Path relativeConfig = 
Paths.get("").toAbsolutePath().relativize(customConfig);
+    OzoneLocal ozoneLocal = new OzoneLocal();
+
+    ozoneLocal.getCmd().parseArgs("--conf", relativeConfig.toString(), "run");
+
+    OzoneConfiguration seed = ozoneLocal.getOzoneConf();
+    assertEquals("THREE", seed.get(OZONE_REPLICATION));
+    String[] sources = seed.getPropertySources(OZONE_REPLICATION);
+    String source = sources[sources.length - 1];
+    assertEquals(customConfig.normalize().toString(), source);
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(
+        tempDir.resolve("local-ozone")).build();
+
+    IOException error = assertThrows(IOException.class, () -> {
+      try (LocalOzoneCluster cluster = new LocalOzoneCluster(config, seed)) {
+        cluster.prepareConfiguration();
+      }
+    });
+
+    assertTrue(error.getMessage().contains(source), error.getMessage());
+  }
+
+  @Test
+  void fileUriConfigPathLoadsTheNamedFile(@TempDir Path tempDir) throws 
Exception {
+    Path customConfig = tempDir.resolve("my-ozone-site.xml").toAbsolutePath();
+    Files.write(customConfig, ("<configuration><property><name>" + 
OZONE_REPLICATION
+        + 
"</name><value>THREE</value></property></configuration>").getBytes(UTF_8));
+    OzoneLocal ozoneLocal = new OzoneLocal();
+
+    ozoneLocal.getCmd().parseArgs("--conf", customConfig.toUri().toString(), 
"run");
+
+    assertEquals("THREE", ozoneLocal.getOzoneConf().get(OZONE_REPLICATION));
+  }
+
+  @Test
+  void emptyConfigPathIsRejectedAtParse() {
+    OzoneLocal ozoneLocal = new OzoneLocal();
+
+    assertThrows(CommandLine.ParameterException.class,
+        () -> ozoneLocal.getCmd().parseArgs("--conf", "", "run"));
+  }
+
+  @Test
+  void startupFailureHintOnlySuggestsMissingDetail() {
+    String noDetail = OzoneLocal.startupFailureHint(false, false);
+    assertTrue(noDetail.contains("--loglevel INFO"), noDetail);
+    assertTrue(noDetail.contains("--verbose"), noDetail);
+
+    String logsEnabled = OzoneLocal.startupFailureHint(true, false);
+    assertFalse(logsEnabled.contains("--loglevel INFO"), logsEnabled);
+    assertTrue(logsEnabled.contains("--verbose"), logsEnabled);
+
+    String verboseEnabled = OzoneLocal.startupFailureHint(false, true);
+    assertTrue(verboseEnabled.contains("--loglevel INFO"), verboseEnabled);
+    assertFalse(verboseEnabled.contains("--verbose"), verboseEnabled);
+
+    assertNull(OzoneLocal.startupFailureHint(true, true));
+  }
+
   @Test
   void runCommandOptionsUseEnvironmentDefaults() throws Exception {
     assertEnvDefault("dataDir", OzoneLocal.ENV_DATA_DIR,
@@ -230,7 +340,7 @@ void resolveConfigAllowsS3gAndEphemeralToBeNegated() {
 
   @Test
   void resolveConfigRejectsInvalidFormat() {
-    assertParseError("--format", "sometimes", "--format");
+    assertParseError("--format", "sometimes", "sometimes");
   }
 
   @Test
@@ -250,7 +360,7 @@ void resolveConfigRejectsDatanodeCountBelowOne() {
 
   @Test
   void resolveConfigRejectsInvalidDuration() {
-    assertParseError("--startup-timeout", "forever", "--startup-timeout");
+    assertParseError("--startup-timeout", "forever", "Invalid duration 
'forever'");
   }
 
   @Test
@@ -258,6 +368,18 @@ void resolveConfigRejectsNonPositiveDuration() {
     assertConfigError("--startup-timeout", "0s", "--startup-timeout");
   }
 
+  /** Without the unit check "120" parsed as 120 milliseconds; the quotes are 
asserted because "120s" is in the hint. */
+  @Test
+  void resolveConfigRejectsDurationWithoutTimeUnit() {
+    assertParseError("--startup-timeout", "120", "Missing time unit in '120'");
+  }
+
+  @Test
+  void resolveConfigAcceptsHadoopStyleMinutes() {
+    assertEquals(Duration.ofMinutes(2), resolve("--startup-timeout", "2m")
+        .getStartupTimeout());
+  }
+
   @Test
   void resolveConfigRejectsInvalidPath() {
     assertParseError("--data-dir", "\0", "--data-dir");


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to