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 8664dc4fc3c HDDS-14932. Optional Recon Start Up (#10760)
8664dc4fc3c is described below

commit 8664dc4fc3cd29b7508d6ce5fa532d424abe7801
Author: Chun-Hung Tseng <[email protected]>
AuthorDate: Wed Oct 14 10:50:25 2026 +0200

    HDDS-14932. Optional Recon Start Up (#10760)
    
    Co-authored-by: Siyao Meng <[email protected]>
---
 hadoop-hdds/docs/content/tools/_index.md           |   2 +-
 hadoop-ozone/integration-test-recon/pom.xml        |   5 +
 .../hadoop/ozone/local/TestLocalOzoneRecon.java    | 127 ++++++++++++++
 .../hadoop/ozone/recon/ConfigurationProvider.java  |   7 +-
 .../org/apache/hadoop/ozone/recon/ReconServer.java |   1 +
 .../scm/ReconStorageContainerManagerFacade.java    |   8 +-
 hadoop-ozone/tools/pom.xml                         |   4 +
 .../hadoop/ozone/local/LocalOzoneCluster.java      | 172 +++++++++++++++++--
 .../ozone/local/LocalOzoneClusterConfig.java       |  31 ++++
 .../hadoop/ozone/local/LocalOzoneRuntime.java      |  14 ++
 .../org/apache/hadoop/ozone/local/OzoneLocal.java  |  28 ++-
 .../hadoop/ozone/local/TestLocalOzoneCluster.java  | 190 +++++++++++++++++++++
 .../ozone/local/TestLocalOzoneClusterConfig.java   |   9 +
 .../apache/hadoop/ozone/local/TestOzoneLocal.java  |  71 ++++++++
 14 files changed, 650 insertions(+), 19 deletions(-)

diff --git a/hadoop-hdds/docs/content/tools/_index.md 
b/hadoop-hdds/docs/content/tools/_index.md
index adbd4740f87..8bb437c8421 100644
--- a/hadoop-hdds/docs/content/tools/_index.md
+++ b/hadoop-hdds/docs/content/tools/_index.md
@@ -44,7 +44,7 @@ Client commands:
    * **sh** -  Primary command line interface for ozone to manage 
volumes/buckets/keys.
    * **fs** - Runs a command on ozone file system (similar to `hdfs dfs`)
    * **local** - Runs a single-node local Ozone cluster (SCM, OM, datanodes,
-   and optional S3 Gateway) in one process for development.
+   and optional S3 Gateway and Recon) in one process for development.
    * **version** - Prints the version of Ozone and HDDS.
 
 
diff --git a/hadoop-ozone/integration-test-recon/pom.xml 
b/hadoop-ozone/integration-test-recon/pom.xml
index 33e1b93493d..6286e136eda 100644
--- a/hadoop-ozone/integration-test-recon/pom.xml
+++ b/hadoop-ozone/integration-test-recon/pom.xml
@@ -185,6 +185,11 @@
       <artifactId>ozone-reconcodegen</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.ozone</groupId>
+      <artifactId>ozone-tools</artifactId>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.apache.ratis</groupId>
       <artifactId>ratis-common</artifactId>
diff --git 
a/hadoop-ozone/integration-test-recon/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneRecon.java
 
b/hadoop-ozone/integration-test-recon/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneRecon.java
new file mode 100644
index 00000000000..1c768a7a44e
--- /dev/null
+++ 
b/hadoop-ozone/integration-test-recon/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneRecon.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.local;
+
+import static 
org.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_SCM_CONTAINER_SYNC_TASK_INTERVAL_DELAY;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.ConnectException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.server.http.BaseHttpServer;
+import org.apache.ozone.test.GenericTestUtils;
+import org.apache.ozone.test.GenericTestUtils.LogCapturer;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Integration tests for Recon in the {@code ozone local} runtime.
+ */
+class TestLocalOzoneRecon {
+
+  @TempDir
+  private Path tempDir;
+
+  static Stream<Arguments> services() {
+    return Stream.of(false, true).flatMap(s3g -> Stream.of(false, 
true).map(recon -> Arguments.of(s3g, recon)));
+  }
+
+  /** A 200 from Recon's /api/v1/clusterState shows that Recon resources can 
be injected in the shared JVM. */
+  @ParameterizedTest(name = "s3g={0}, recon={1}")
+  @MethodSource("services")
+  void enabledServicesServeAndStopOnClose(boolean s3gEnabled, boolean 
reconEnabled) throws Exception {
+    List<String> endpoints = new ArrayList<>();
+    try (LocalOzoneCluster cluster =
+        new LocalOzoneCluster(config(s3gEnabled, reconEnabled), new 
OzoneConfiguration())) {
+      cluster.start();
+
+      assertEquals(reconEnabled, syncThreadRunning());
+      if (reconEnabled) {
+        assertEquals(200, responseCode(cluster.getReconEndpoint() + 
"/api/v1/clusterState"));
+        endpoints.add(cluster.getReconEndpoint());
+      }
+      if (s3gEnabled) {
+        assertTrue(responseCode(cluster.getS3Endpoint()) > 0);
+        endpoints.add(cluster.getS3Endpoint());
+      } else {
+        assertEquals(-1, cluster.getS3gPort());
+      }
+    }
+
+    GenericTestUtils.waitFor(() -> !syncThreadRunning(), 100, 10_000);
+    for (String endpoint : endpoints) {
+      assertThrows(ConnectException.class, () -> responseCode(endpoint), 
endpoint);
+    }
+  }
+
+  /**
+   * Regression test: ReconServer#call() logged an initialization failure and 
returned normally, so when
+   * ReconStorageContainerManagerFacade#start() failed after the Recon HTTP 
server started, start() succeeded without
+   * the Recon datanode RPC server. A zero sync interval fails it there, 
because
+   * ScheduledExecutorService#scheduleWithFixedDelay rejects a delay that is 
not positive.
+   */
+  @ParameterizedTest(name = "s3g={0}")
+  @ValueSource(booleans = {false, true})
+  void startFailsAndStopsReconWhenReconFailsAfterHttpServerStarts(boolean 
s3gEnabled) throws Exception {
+    OzoneConfiguration conf = new OzoneConfiguration();
+    conf.set(OZONE_RECON_SCM_CONTAINER_SYNC_TASK_INTERVAL_DELAY, "0s");
+
+    try (LogCapturer httpLogs = LogCapturer.captureLogs(BaseHttpServer.class);
+        LocalOzoneCluster cluster = new LocalOzoneCluster(config(s3gEnabled, 
true), conf)) {
+      assertThrows(IllegalArgumentException.class, cluster::start);
+      assertThat(httpLogs.getOutput()).contains("HTTP server of recon 
listening");
+      assertThrows(ConnectException.class, () -> 
responseCode(cluster.getReconEndpoint()));
+    }
+  }
+
+  private LocalOzoneClusterConfig config(boolean s3gEnabled, boolean 
reconEnabled) {
+    return 
LocalOzoneClusterConfig.builder(tempDir.resolve("local-ozone-recon"))
+        .setS3gEnabled(s3gEnabled)
+        .setReconEnabled(reconEnabled)
+        .setStartupTimeout(Duration.ofMinutes(2))
+        .build();
+  }
+
+  private static boolean syncThreadRunning() {
+    return Thread.getAllStackTraces().keySet().stream()
+        .anyMatch(thread -> thread.getName().contains("SyncSCMContainerInfo"));
+  }
+
+  private static int responseCode(String endpoint) throws Exception {
+    HttpURLConnection connection = (HttpURLConnection) new 
URL(endpoint).openConnection();
+    try {
+      connection.setConnectTimeout(1_000);
+      connection.setReadTimeout(1_000);
+      return connection.getResponseCode();
+    } finally {
+      connection.disconnect();
+    }
+  }
+}
diff --git 
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ConfigurationProvider.java
 
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ConfigurationProvider.java
index c440ba25850..4cd1b48ee66 100644
--- 
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ConfigurationProvider.java
+++ 
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ConfigurationProvider.java
@@ -17,7 +17,6 @@
 
 package org.apache.hadoop.ozone.recon;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.inject.Provider;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.conf.Configuration.DeprecationDelta;
@@ -50,16 +49,14 @@ private static void addDeprecations() {
     });
   }
 
-  @VisibleForTesting
   public static void setConfiguration(OzoneConfiguration conf) {
-    // Nullity check is used in case the configuration was already set
-    // in the MiniOzoneCluster
+    // First writer wins: ReconService and LocalOzoneCluster set this before 
ReconServer#call()
+    // reaches it, and their configuration is the one the in-JVM server has to 
run with.
     if (configuration == null) {
       ConfigurationProvider.configuration = conf;
     }
   }
 
-  @VisibleForTesting
   public static void resetConfiguration() {
     configuration = null;
   }
diff --git 
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
 
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
index deb9791b152..d015de32dc9 100644
--- 
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
+++ 
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/ReconServer.java
@@ -209,6 +209,7 @@ public Void call() throws Exception {
     } catch (Exception e) {
       LOG.error("Error during initializing Recon server.", e);
       updateAndLogReconHealthStatus();
+      throw e;
     }
 
     ShutdownHookManager.get().addShutdownHook(() -> {
diff --git 
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/scm/ReconStorageContainerManagerFacade.java
 
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/scm/ReconStorageContainerManagerFacade.java
index 6c5ce522423..47c8e256b29 100644
--- 
a/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/scm/ReconStorageContainerManagerFacade.java
+++ 
b/hadoop-ozone/recon/src/main/java/org/apache/hadoop/ozone/recon/scm/ReconStorageContainerManagerFacade.java
@@ -175,6 +175,7 @@ public class ReconStorageContainerManagerFacade
   private final ReconStorageContainerSyncHelper containerSyncHelper;
   private final ReconScmContainerSyncMetrics containerSyncMetrics;
   private final ExecutorService scmSnapshotExecutor;
+  private ScheduledExecutorService scmContainerSyncScheduler;
   private final Object scmSnapshotLock = new Object();
   private Future<?> scmSnapshotFuture;
   private ScmDbSnapshotSyncStatus scmSnapshotStatus =
@@ -587,7 +588,7 @@ public void start() {
           "Recon ScmDatanodeProtocol RPC server",
           getDatanodeProtocolServer().getDatanodeRpcAddress()));
     }
-    ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1,
+    scmContainerSyncScheduler = Executors.newScheduledThreadPool(1,
         new ThreadFactoryBuilder().setNameFormat(threadNamePrefix +
                                                      "SyncSCMContainerInfo-%d")
             .build());
@@ -615,7 +616,7 @@ public void start() {
         TimeUnit.MILLISECONDS);
     LOG.debug("Started the SCM Container Info sync scheduler (interval={}ms, 
initialDelay={}ms).",
         syncInterval, syncInitialDelay);
-    scheduler.scheduleWithFixedDelay(() -> {
+    scmContainerSyncScheduler.scheduleWithFixedDelay(() -> {
       if (!isSyncDataFromSCMRunning.compareAndSet(false, true)) {
         LOG.debug("SCM container info sync is already running; skipping this 
cycle.");
         return;
@@ -661,6 +662,9 @@ public void join() {
    */
   @Override
   public void stop() {
+    if (scmContainerSyncScheduler != null) {
+      scmContainerSyncScheduler.shutdownNow();
+    }
     getDatanodeProtocolServer().stop();
     reconScmTasks.forEach(ReconScmTask::stop);
     try {
diff --git a/hadoop-ozone/tools/pom.xml b/hadoop-ozone/tools/pom.xml
index bdd362d9ba4..ca04dca1f01 100644
--- a/hadoop-ozone/tools/pom.xml
+++ b/hadoop-ozone/tools/pom.xml
@@ -87,6 +87,10 @@
       <groupId>org.apache.ozone</groupId>
       <artifactId>ozone-manager</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.apache.ozone</groupId>
+      <artifactId>ozone-recon</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.ozone</groupId>
       <artifactId>ozone-s3gateway</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 8bb057f601b..8484f7c877d 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
@@ -25,6 +25,12 @@
 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.recon.ReconConfigKeys.OZONE_RECON_ADDRESS_KEY;
+import static 
org.apache.hadoop.hdds.recon.ReconConfigKeys.OZONE_RECON_DATANODE_ADDRESS_KEY;
+import static 
org.apache.hadoop.hdds.recon.ReconConfigKeys.OZONE_RECON_DATANODE_BIND_HOST_KEY;
+import static 
org.apache.hadoop.hdds.recon.ReconConfigKeys.OZONE_RECON_HTTPS_ADDRESS_KEY;
+import static 
org.apache.hadoop.hdds.recon.ReconConfigKeys.OZONE_RECON_HTTP_ADDRESS_KEY;
+import static 
org.apache.hadoop.hdds.recon.ReconConfigKeys.OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD;
 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_BLOCK_CLIENT_ADDRESS_KEY;
@@ -74,6 +80,12 @@
 import static 
org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_OM_SNAPSHOT_DIFF_DB_DIR;
 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.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_DB_DIR;
+import static 
org.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_HTTPS_BIND_HOST_KEY;
+import static 
org.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_HTTP_BIND_HOST_KEY;
+import static 
org.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_HTTP_ENABLED_KEY;
+import static 
org.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_OM_SNAPSHOT_DB_DIR;
+import static 
org.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_SCM_DB_DIR;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTPS_ADDRESS_KEY;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTPS_BIND_HOST_KEY;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_ADDRESS_KEY;
@@ -103,6 +115,11 @@
 import java.util.Properties;
 import java.util.Set;
 import java.util.UUID;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.function.Supplier;
@@ -125,6 +142,9 @@
 import org.apache.hadoop.ozone.container.replication.ReplicationServer;
 import org.apache.hadoop.ozone.om.OMStorage;
 import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.recon.ConfigurationProvider;
+import org.apache.hadoop.ozone.recon.ReconServer;
+import org.apache.hadoop.ozone.recon.ReconSqlDbConfig;
 import org.apache.hadoop.ozone.s3.Gateway;
 import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder;
 import org.apache.ratis.util.function.CheckedSupplier;
@@ -132,7 +152,7 @@
 import org.slf4j.LoggerFactory;
 
 /**
- * Starts the SCM, OM, datanode, and optional S3 Gateway portion of the {@code 
ozone local} runtime.
+ * Starts the SCM, OM, datanode, and optional S3 Gateway and Recon portion of 
the {@code ozone local} runtime.
  */
 public final class LocalOzoneCluster implements LocalOzoneRuntime {
 
@@ -162,6 +182,10 @@ public final class LocalOzoneCluster implements 
LocalOzoneRuntime {
   private static final String OM_RATIS_PORT_KEY = "om.ratis";
   private static final String S3G_HTTP_PORT_KEY = "s3g.http";
   private static final String S3G_WEBADMIN_HTTP_PORT_KEY = "s3g.web.http";
+  private static final String RECON_HTTP_PORT_KEY = "recon.http";
+  private static final String RECON_HTTPS_PORT_KEY = "recon.https";
+  private static final String RECON_DATANODE_PORT_KEY = "recon.datanode";
+  private static final String RECON_DIR_NAME = "recon";
   private static final String DATANODE_PORT_KEY_PREFIX = "dn.";
   private static final String DATANODE_HTTP_PORT_KEY_SUFFIX = "http";
   private static final String DATANODE_CLIENT_PORT_KEY_SUFFIX = "client";
@@ -223,6 +247,7 @@ public final class LocalOzoneCluster implements 
LocalOzoneRuntime {
   private StorageContainerManager scm;
   private OzoneManager om;
   private Gateway s3Gateway;
+  private ReconServer reconServer;
   private final List<HddsDatanodeService> datanodes = new ArrayList<>();
   private boolean previousMetricsMiniClusterMode;
   private boolean metricsMiniClusterModeEnabled;
@@ -254,6 +279,9 @@ public void start() throws Exception {
       startOm(prepared.getConfiguration());
       startDatanodes(prepared.getDatanodeConfigurations());
       waitForClusterReadiness(config.getStartupTimeout());
+      if (config.isReconEnabled()) {
+        startRecon(prepared.getConfiguration());
+      }
       if (config.isS3gEnabled()) {
         startS3Gateway(prepared.getConfiguration());
       }
@@ -282,13 +310,15 @@ PreparedConfiguration prepareConfiguration() throws 
IOException {
     int scmPort = configureScm(conf, persistedPorts, portAllocator);
     int omPort = configureOm(conf, persistedPorts, portAllocator);
     int s3gPort = configureS3Gateway(conf, persistedPorts, portAllocator);
+    int reconPort = configureRecon(conf, persistedPorts, portAllocator);
     List<OzoneConfiguration> datanodeConfigurations =
         configureDatanodes(conf, persistedPorts, portAllocator);
 
     prepareStorageLayout();
     createServiceDirectories();
     persistedPorts.store();
-    preparedConfiguration = new PreparedConfiguration(conf, scmPort, omPort, 
s3gPort, datanodeConfigurations);
+    preparedConfiguration = new PreparedConfiguration(conf, scmPort, omPort, 
s3gPort, reconPort,
+        datanodeConfigurations);
     return preparedConfiguration;
   }
 
@@ -332,6 +362,22 @@ InetSocketAddress getS3gBoundAddress() {
     return s3Gateway != null ? s3Gateway.getHttpAddress() : null;
   }
 
+  @Override
+  public int getReconPort() {
+    if (!config.isReconEnabled()) {
+      return -1;
+    }
+    return preparedConfiguration == null ? config.getReconPort() : 
preparedConfiguration.getReconPort();
+  }
+
+  @Override
+  public String getReconEndpoint() {
+    if (!config.isReconEnabled()) {
+      return "";
+    }
+    return "http://"; + getDisplayHost() + ":" + getReconPort();
+  }
+
   @Override
   public void close() throws IOException {
     if (closed) {
@@ -358,7 +404,7 @@ private void stopServices() {
     try {
       // Logged rather than closeQuietly: stopServices() is also the start() 
rollback, where a silent
       // failure hides leaked threads and ports.
-      IOUtils.close(LOG, this::stopS3Gateway, this::stopDatanodes, 
this::stopOm, this::stopScm);
+      IOUtils.close(LOG, this::stopS3Gateway, this::stopRecon, 
this::stopDatanodes, this::stopOm, this::stopScm);
     } finally {
       restoreSameJvmMetricsMode();
     }
@@ -372,6 +418,15 @@ private void stopS3Gateway() throws Exception {
     }
   }
 
+  private void stopRecon() {
+    ReconServer service = reconServer;
+    reconServer = null;
+    if (service != null) {
+      service.stop();
+      service.join();
+    }
+  }
+
   private void stopDatanodes() {
     List<AutoCloseable> stoppers = new ArrayList<>();
     for (int i = datanodes.size() - 1; i >= 0; i--) {
@@ -403,6 +458,14 @@ private void stopScm() {
   }
 
   private void configureLocalDefaults(OzoneConfiguration conf) throws 
IOException {
+    if (config.isS3gEnabled() || config.isReconEnabled()) {
+      // getS3Endpoint() and getReconEndpoint() advertise the HTTP listener, 
which a policy without HTTP leaves unbound.
+      HttpConfig.Policy httpPolicy =
+          HttpConfig.Policy.fromString(conf.get(OZONE_HTTP_POLICY_KEY, 
OZONE_HTTP_POLICY_DEFAULT));
+      if (httpPolicy != null && !httpPolicy.isHttpEnabled()) {
+        rejectUserConfigured(conf, OZONE_HTTP_POLICY_KEY, "HTTP_ONLY or 
HTTP_AND_HTTPS");
+      }
+    }
     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.
@@ -651,12 +714,6 @@ private int configureS3Gateway(OzoneConfiguration conf, 
PersistedPortState persi
     if (!config.isS3gEnabled()) {
       return -1;
     }
-    // getS3Endpoint() advertises the HTTP listener, which a policy without 
HTTP leaves unbound.
-    HttpConfig.Policy httpPolicy =
-        HttpConfig.Policy.fromString(conf.get(OZONE_HTTP_POLICY_KEY, 
OZONE_HTTP_POLICY_DEFAULT));
-    if (httpPolicy != null && !httpPolicy.isHttpEnabled()) {
-      rejectUserConfigured(conf, OZONE_HTTP_POLICY_KEY, "HTTP_ONLY or 
HTTP_AND_HTTPS");
-    }
     // getS3gPort() reads the port back off this listener.
     setLocalOverride(conf, OZONE_S3G_HTTP_ENABLED_KEY, true);
     // S3StorageType maps an explicit STANDARD to RATIS/THREE regardless of 
the local STAND_ALONE/ONE replication.
@@ -677,6 +734,39 @@ private int configureS3Gateway(OzoneConfiguration conf, 
PersistedPortState persi
     return s3gHttpPort;
   }
 
+  private int configureRecon(OzoneConfiguration conf, PersistedPortState 
persistedPorts,
+      PortAllocator portAllocator) throws IOException {
+    if (!config.isReconEnabled()) {
+      return -1;
+    }
+    // getReconEndpoint() advertises this listener.
+    setLocalOverride(conf, OZONE_RECON_HTTP_ENABLED_KEY, true);
+    Path reconDir = config.getDataDir().resolve(RECON_DIR_NAME);
+    conf.setIfUnset(OZONE_RECON_DB_DIR, reconDir.toString());
+    conf.setIfUnset(OZONE_RECON_OM_SNAPSHOT_DB_DIR, reconDir.toString());
+    conf.setIfUnset(OZONE_RECON_SCM_DB_DIR, reconDir.toString());
+
+    ReconSqlDbConfig dbConfig = conf.getObject(ReconSqlDbConfig.class);
+    dbConfig.setJdbcUrl("jdbc:derby:" + 
reconDir.resolve("ozone_recon_derby.db"));
+    conf.setFromObject(dbConfig);
+
+    int reconHttpPort = reservePort(portAllocator, persistedPorts, 
RECON_HTTP_PORT_KEY, config.getReconPort());
+    int reconHttpsPort = reservePort(portAllocator, persistedPorts, 
RECON_HTTPS_PORT_KEY, 0);
+    int reconDatanodePort = reservePort(portAllocator, persistedPorts, 
RECON_DATANODE_PORT_KEY, 0);
+
+    conf.set(OZONE_RECON_ADDRESS_KEY, address(config.getHost(), 
reconDatanodePort));
+    conf.set(OZONE_RECON_DATANODE_ADDRESS_KEY, address(config.getHost(), 
reconDatanodePort));
+    conf.set(OZONE_RECON_DATANODE_BIND_HOST_KEY, config.getBindHost());
+    conf.set(OZONE_RECON_HTTP_ADDRESS_KEY, address(config.getHost(), 
reconHttpPort));
+    conf.set(OZONE_RECON_HTTP_BIND_HOST_KEY, config.getBindHost());
+    conf.set(OZONE_RECON_HTTPS_ADDRESS_KEY, address(config.getHost(), 
reconHttpsPort));
+    conf.set(OZONE_RECON_HTTPS_BIND_HOST_KEY, config.getBindHost());
+    // Recon's start-up blocks until it leaves safe mode and an empty local 
cluster never leaves
+    // it on its own, so the shipped 300s default would stall every start for 
five minutes.
+    setLocalOverrideDuration(conf, OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD, 
"10s");
+    return reconHttpPort;
+  }
+
   private List<OzoneConfiguration> configureDatanodes(OzoneConfiguration conf,
       PersistedPortState persistedPorts, PortAllocator portAllocator)
       throws IOException {
@@ -844,6 +934,54 @@ private void startS3Gateway(OzoneConfiguration conf) 
throws Exception {
     s3Gateway.call();
   }
 
+  /**
+   * Runs a blocking in-JVM service startup under {@code timeout}, so the 
launcher fails fast and
+   * rolls back instead of hanging if a startup ever blocks. {@code 
startupCleanup} is the only
+   * stopper for an attempt that did not finish: a startup that ignores the 
interrupt keeps running
+   * after {@code stopServices()} has rolled back, so on a timeout or an 
interrupt the cleanup is
+   * queued behind it on the same single thread rather than run here. The 
caller assigns its field
+   * only after this returns, so a failed attempt is never reachable from the 
rollback.
+   */
+  static <V> V executeWithinStartupTimeout(String serviceName, Callable<V> 
startup,
+      AutoCloseable startupCleanup, Duration timeout) throws Exception {
+    ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> {
+      Thread thread = new Thread(runnable, "local-startup-" + serviceName);
+      thread.setDaemon(true);
+      return thread;
+    });
+    Future<V> future = executor.submit(startup);
+    try {
+      return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
+    } catch (TimeoutException ex) {
+      future.cancel(true);
+      executor.submit(() -> IOUtils.close(LOG, startupCleanup));
+      throw new IOException("Local " + serviceName + " did not start within " 
+ timeout + ".", ex);
+    } catch (InterruptedException ex) {
+      future.cancel(true);
+      executor.submit(() -> IOUtils.close(LOG, startupCleanup));
+      Thread.currentThread().interrupt();
+      throw ex;
+    } catch (ExecutionException ex) {
+      IOUtils.close(LOG, startupCleanup);
+      Throwable cause = ex.getCause();
+      throw cause instanceof Exception ? (Exception) cause : ex;
+    } finally {
+      // shutdown(), not shutdownNow(): the timeout path queues the cleanup, 
which shutdownNow()
+      // would discard. The worker is a daemon thread, so it cannot keep the 
JVM alive.
+      executor.shutdown();
+    }
+  }
+
+  private void startRecon(OzoneConfiguration conf) throws Exception {
+    // ReconServer reads ConfigurationProvider; setConfiguration() keeps the 
first value.
+    ConfigurationProvider.resetConfiguration();
+    ConfigurationProvider.setConfiguration(new OzoneConfiguration(conf));
+    ReconServer recon = new ReconServer();
+    recon.getCmd().parseArgs();
+    executeWithinStartupTimeout("Recon", recon::call, recon::stop, 
config.getStartupTimeout());
+    reconServer = recon;
+  }
+
   private void waitForClusterReadiness(Duration timeout) throws Exception {
     waitForReadiness(this::clusterReadinessBlocker, "Ozone cluster", timeout);
   }
@@ -974,6 +1112,9 @@ private void createServiceDirectories() throws IOException 
{
       Files.createDirectories(datanodeDir.resolve(OZONE_METADATA_DIR_NAME));
       Files.createDirectories(datanodeDir.resolve(DATA_DIR_NAME));
     }
+    if (config.isReconEnabled()) {
+      Files.createDirectories(dataDir.resolve(RECON_DIR_NAME));
+    }
   }
 
   private void createBaseLayout() throws IOException {
@@ -1021,6 +1162,11 @@ private String[] requiredPersistedPortKeys() {
       keys.add(S3G_HTTP_PORT_KEY);
       keys.add(S3G_WEBADMIN_HTTP_PORT_KEY);
     }
+    if (config.isReconEnabled()) {
+      keys.add(RECON_HTTP_PORT_KEY);
+      keys.add(RECON_HTTPS_PORT_KEY);
+      keys.add(RECON_DATANODE_PORT_KEY);
+    }
     for (int index = 0; index < config.getDatanodes(); index++) {
       for (String suffix : DATANODE_PORT_KEY_SUFFIXES) {
         keys.add(datanodePortKey(index, suffix));
@@ -1084,15 +1230,17 @@ static final class PreparedConfiguration {
     private final int scmPort;
     private final int omPort;
     private final int s3gPort;
+    private final int reconPort;
     private final List<OzoneConfiguration> datanodeConfigurations;
 
-    PreparedConfiguration(OzoneConfiguration configuration, int scmPort, int 
omPort, int s3gPort,
+    PreparedConfiguration(OzoneConfiguration configuration, int scmPort, int 
omPort, int s3gPort, int reconPort,
         List<OzoneConfiguration> datanodeConfigurations) {
       this.configuration = Objects.requireNonNull(configuration,
           "configuration");
       this.scmPort = scmPort;
       this.omPort = omPort;
       this.s3gPort = s3gPort;
+      this.reconPort = reconPort;
       this.datanodeConfigurations = Collections.unmodifiableList(
           new ArrayList<>(Objects.requireNonNull(datanodeConfigurations,
               "datanodeConfigurations")));
@@ -1114,6 +1262,10 @@ int getS3gPort() {
       return s3gPort;
     }
 
+    int getReconPort() {
+      return reconPort;
+    }
+
     List<OzoneConfiguration> getDatanodeConfigurations() {
       return datanodeConfigurations;
     }
diff --git 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneClusterConfig.java
 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneClusterConfig.java
index e2d62d2f860..20c0de8219d 100644
--- 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneClusterConfig.java
+++ 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneClusterConfig.java
@@ -43,6 +43,7 @@ public final class LocalOzoneClusterConfig {
   static final String DEFAULT_DATANODES_VALUE = "1";
   static final String DEFAULT_PORT_VALUE = "0";
   static final String DEFAULT_S3G_ENABLED_VALUE = "true";
+  static final String DEFAULT_RECON_ENABLED_VALUE = "false";
   static final String DEFAULT_EPHEMERAL_VALUE = "false";
   static final String DEFAULT_STARTUP_TIMEOUT_VALUE = "PT2M";
 
@@ -60,6 +61,8 @@ public final class LocalOzoneClusterConfig {
   static final int DEFAULT_PORT = Integer.parseInt(DEFAULT_PORT_VALUE);
   static final boolean DEFAULT_S3G_ENABLED =
       Boolean.parseBoolean(DEFAULT_S3G_ENABLED_VALUE);
+  static final boolean DEFAULT_RECON_ENABLED =
+      Boolean.parseBoolean(DEFAULT_RECON_ENABLED_VALUE);
   static final boolean DEFAULT_EPHEMERAL =
       Boolean.parseBoolean(DEFAULT_EPHEMERAL_VALUE);
   static final Duration DEFAULT_STARTUP_TIMEOUT =
@@ -78,6 +81,8 @@ public final class LocalOzoneClusterConfig {
   private final int omPort;
   private final int s3gPort;
   private final boolean s3gEnabled;
+  private final int reconPort;
+  private final boolean reconEnabled;
   private final boolean ephemeral;
   private final Duration startupTimeout;
 
@@ -94,6 +99,8 @@ private LocalOzoneClusterConfig(Builder builder) {
     omPort = builder.omPort;
     s3gPort = builder.s3gPort;
     s3gEnabled = builder.s3gEnabled;
+    reconPort = builder.reconPort;
+    reconEnabled = builder.reconEnabled;
     ephemeral = builder.ephemeral;
     startupTimeout = Objects.requireNonNull(builder.startupTimeout,
         "startupTimeout");
@@ -150,6 +157,18 @@ public boolean isS3gEnabled() {
     return s3gEnabled;
   }
 
+  /**
+   * Returns the Recon HTTP port. Port {@code 0} asks the runtime to choose an
+   * available local port.
+   */
+  public int getReconPort() {
+    return reconPort;
+  }
+
+  public boolean isReconEnabled() {
+    return reconEnabled;
+  }
+
   /**
    * Returns whether the local runtime should remove its data directory when it
    * shuts down.
@@ -220,6 +239,8 @@ public static final class Builder {
     private int omPort = DEFAULT_PORT;
     private int s3gPort = DEFAULT_PORT;
     private boolean s3gEnabled = DEFAULT_S3G_ENABLED;
+    private int reconPort = DEFAULT_PORT;
+    private boolean reconEnabled = DEFAULT_RECON_ENABLED;
     private boolean ephemeral = DEFAULT_EPHEMERAL;
     private Duration startupTimeout = DEFAULT_STARTUP_TIMEOUT;
 
@@ -267,6 +288,16 @@ public Builder setS3gEnabled(boolean value) {
       return this;
     }
 
+    public Builder setReconPort(int value) {
+      reconPort = value;
+      return this;
+    }
+
+    public Builder setReconEnabled(boolean value) {
+      reconEnabled = value;
+      return this;
+    }
+
     public Builder setEphemeral(boolean value) {
       ephemeral = value;
       return this;
diff --git 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneRuntime.java
 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneRuntime.java
index ed0176bd234..59198818afc 100644
--- 
a/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneRuntime.java
+++ 
b/hadoop-ozone/tools/src/main/java/org/apache/hadoop/ozone/local/LocalOzoneRuntime.java
@@ -71,6 +71,20 @@ public interface LocalOzoneRuntime extends AutoCloseable {
    */
   String getS3Endpoint();
 
+  /**
+   * Returns the Recon HTTP port for this local runtime.
+   *
+   * @return Recon port, or -1 when Recon is disabled
+   */
+  int getReconPort();
+
+  /**
+   * Returns the full Recon endpoint shown to users.
+   *
+   * @return Recon endpoint, including scheme, host, and port; empty when 
Recon is disabled
+   */
+  String getReconEndpoint();
+
   /**
    * Stops the local runtime and releases resources created during startup.
    *
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 f2565361148..b0ce83d466f 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
@@ -60,6 +60,8 @@ public class OzoneLocal extends GenericCli {
   static final String ENV_OM_PORT = "OZONE_LOCAL_OM_PORT";
   static final String ENV_S3G_ENABLED = "OZONE_LOCAL_S3G_ENABLED";
   static final String ENV_S3G_PORT = "OZONE_LOCAL_S3G_PORT";
+  static final String ENV_RECON_ENABLED = "OZONE_LOCAL_RECON_ENABLED";
+  static final String ENV_RECON_PORT = "OZONE_LOCAL_RECON_PORT";
   static final String ENV_EPHEMERAL = "OZONE_LOCAL_EPHEMERAL";
   static final String ENV_STARTUP_TIMEOUT = "OZONE_LOCAL_STARTUP_TIMEOUT";
 
@@ -83,6 +85,12 @@ public class OzoneLocal extends GenericCli {
       + LocalOzoneClusterConfig.DEFAULT_S3G_ENABLED_VALUE + "}";
   private static final String DEFAULT_S3G_PORT_VALUE = "${env:" + ENV_S3G_PORT
       + ":-" + LocalOzoneClusterConfig.DEFAULT_PORT_VALUE + "}";
+  private static final String DEFAULT_RECON_ENABLED_VALUE = "${env:"
+      + ENV_RECON_ENABLED + ":-"
+      + LocalOzoneClusterConfig.DEFAULT_RECON_ENABLED_VALUE + "}";
+  private static final String DEFAULT_RECON_PORT_VALUE = "${env:"
+      + ENV_RECON_PORT + ":-" + LocalOzoneClusterConfig.DEFAULT_PORT_VALUE
+      + "}";
   private static final String DEFAULT_EPHEMERAL_VALUE = "${env:"
       + ENV_EPHEMERAL + ":-"
       + LocalOzoneClusterConfig.DEFAULT_EPHEMERAL_VALUE + "}";
@@ -150,7 +158,7 @@ private StartupFailureException(Throwable cause) {
   }
 
   @Command(name = "run",
-      description = "Start SCM, OM, datanodes, and optional S3 Gateway in one 
local process")
+      description = "Start SCM, OM, datanodes, and optional S3 Gateway and 
Recon in one local process")
   static class RunCommand extends AbstractSubcommand implements Callable<Void> 
{
 
     @Option(names = "--data-dir",
@@ -201,6 +209,18 @@ static class RunCommand extends AbstractSubcommand 
implements Callable<Void> {
         description = "Enable S3 Gateway")
     private boolean s3gEnabled;
 
+    @Option(names = "--recon-port",
+        defaultValue = DEFAULT_RECON_PORT_VALUE,
+        description = "Recon HTTP port (0 means auto-allocate)")
+    private int reconPort;
+
+    @Option(names = "--recon",
+        negatable = true,
+        defaultValue = DEFAULT_RECON_ENABLED_VALUE,
+        fallbackValue = "true",
+        description = "Enable Recon")
+    private boolean reconEnabled;
+
     @Option(names = "--ephemeral",
         negatable = true,
         defaultValue = DEFAULT_EPHEMERAL_VALUE,
@@ -253,6 +273,9 @@ private void printSummary(LocalOzoneRuntime runtime, 
LocalOzoneClusterConfig con
         writer.println("Use path-style addressing (AWS CLI: aws configure set 
default.s3.addressing_style path).");
         writer.println("With security off (the default), the S3 Gateway 
accepts any credentials.");
       }
+      if (config.isReconEnabled()) {
+        writer.println("Recon endpoint: " + runtime.getReconEndpoint());
+      }
       writer.println("Press Ctrl+C to stop.");
       writer.flush();
     }
@@ -292,6 +315,7 @@ LocalOzoneClusterConfig resolveConfig() {
       validatePort(scmPort, "--scm-port");
       validatePort(omPort, "--om-port");
       validatePort(s3gPort, "--s3g-port");
+      validatePort(reconPort, "--recon-port");
       validateStartupTimeout();
 
       return LocalOzoneClusterConfig.builder(dataDir)
@@ -303,6 +327,8 @@ LocalOzoneClusterConfig resolveConfig() {
           .setOmPort(omPort)
           .setS3gPort(s3gPort)
           .setS3gEnabled(s3gEnabled)
+          .setReconPort(reconPort)
+          .setReconEnabled(reconEnabled)
           .setEphemeral(ephemeral)
           .setStartupTimeout(startupTimeout)
           .build();
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 6da5f287e24..0bdffc4d304 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
@@ -23,6 +23,8 @@
 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.recon.ReconConfigKeys.OZONE_RECON_HTTP_ADDRESS_KEY;
+import static 
org.apache.hadoop.hdds.recon.ReconConfigKeys.OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD;
 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;
@@ -38,6 +40,7 @@
 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.apache.hadoop.ozone.recon.ReconServerConfigKeys.OZONE_RECON_HTTP_ENABLED_KEY;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTPS_ADDRESS_KEY;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_ADDRESS_KEY;
 import static 
org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_BIND_HOST_KEY;
@@ -60,7 +63,10 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.hadoop.hdds.client.ReplicationFactor;
 import org.apache.hadoop.hdds.client.ReplicationType;
@@ -414,6 +420,150 @@ void freshPortSkipsPersistedPorts() throws Exception {
     assertEquals(40000, allocator.reserve(40000));
   }
 
+  /**
+   * configureRecon() runs after configureLocalDefaults(), so the value has to 
be rejected where it is applied
+   * rather than by a check that only covers the keys set earlier.
+   */
+  @Test
+  void reconOverrideRejectsConflictingUserValue() {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD, "9m", 
"test-ozone-site.xml");
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(
+            tempDir.resolve("local-ozone"))
+        .setReconEnabled(true)
+        .build();
+
+    IOException error = assertPrepareFails(config, seed);
+
+    String message = error.getMessage();
+    assertTrue(message.contains(OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD), 
message);
+    assertTrue(message.contains("9m"), message);
+  }
+
+  @Test
+  void reconOverrideAcceptsEquivalentDurationSpelling() throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD, "10000ms", 
"test-ozone-site.xml");
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(
+            tempDir.resolve("local-ozone"))
+        .setReconEnabled(true)
+        .build();
+
+    try (LocalOzoneCluster cluster = new LocalOzoneCluster(config, seed)) {
+      assertEquals("10s", cluster.prepareConfiguration().getConfiguration()
+          .get(OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD));
+    }
+  }
+
+  /**
+   * Regression guard: the Recon default ships in ozone-recon-default.xml, so 
counting only
+   * ozone-default.xml as shipped would reject every run with Recon enabled.
+   */
+  @Test
+  void reconDefaultFromModuleXmlIsNotTreatedAsUserConfig() throws Exception {
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(
+            tempDir.resolve("local-ozone"))
+        .setReconEnabled(true)
+        .build();
+
+    try (LocalOzoneCluster cluster = new LocalOzoneCluster(config,
+        new OzoneConfiguration())) {
+      assertEquals("10s", cluster.prepareConfiguration().getConfiguration()
+          .get(OZONE_RECON_TASK_SAFEMODE_WAIT_THRESHOLD));
+    }
+  }
+
+  @Test
+  void prepareConfigurationReservesReconPortsWhenEnabled() throws Exception {
+    Path dataDir = tempDir.resolve("local-ozone");
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(dataDir)
+        .setReconEnabled(true)
+        .setReconPort(9888)
+        .build();
+
+    try (LocalOzoneCluster cluster = newCluster(config)) {
+      LocalOzoneCluster.PreparedConfiguration prepared =
+          cluster.prepareConfiguration();
+      OzoneConfiguration conf = prepared.getConfiguration();
+
+      assertEquals(9888, prepared.getReconPort());
+      assertEquals(9888, cluster.getReconPort());
+      assertEquals("http://127.0.0.1:9888";, cluster.getReconEndpoint());
+      assertTrue(conf.get(OZONE_RECON_HTTP_ADDRESS_KEY).endsWith(":9888"));
+      assertTrue(Files.isDirectory(dataDir.resolve("recon")));
+    }
+    Properties properties = loadPortState(dataDir);
+    assertPositivePort(properties, "recon.http");
+    assertPositivePort(properties, "recon.https");
+    assertPositivePort(properties, "recon.datanode");
+  }
+
+  @Test
+  void prepareConfigurationSkipsReconWhenDisabled() throws Exception {
+    Path dataDir = tempDir.resolve("local-ozone");
+    LocalOzoneClusterConfig config =
+        LocalOzoneClusterConfig.builder(dataDir).build();
+
+    try (LocalOzoneCluster cluster = newCluster(config)) {
+      LocalOzoneCluster.PreparedConfiguration prepared =
+          cluster.prepareConfiguration();
+
+      assertEquals(-1, prepared.getReconPort());
+      assertEquals(-1, cluster.getReconPort());
+      assertEquals("", cluster.getReconEndpoint());
+    }
+    assertFalse(loadPortState(dataDir).stringPropertyNames().stream()
+        .anyMatch(key -> key.startsWith("recon.")));
+  }
+
+  @Test
+  void prepareConfigurationRejectsDisabledReconHttpListener() {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.setBoolean(OZONE_RECON_HTTP_ENABLED_KEY, false);
+    LocalOzoneClusterConfig config = 
LocalOzoneClusterConfig.builder(tempDir.resolve("local-ozone"))
+        .setReconEnabled(true)
+        .build();
+
+    IOException error = assertPrepareFails(config, seed);
+
+    assertMessageContains(error, OZONE_RECON_HTTP_ENABLED_KEY);
+  }
+
+  @ParameterizedTest
+  @EnumSource(HttpConfig.Policy.class)
+  void prepareConfigurationChecksHttpPolicyForRecon(HttpConfig.Policy policy) 
throws Exception {
+    OzoneConfiguration seed = new OzoneConfiguration();
+    seed.set(OZONE_HTTP_POLICY_KEY, policy.name(), "test-ozone-site.xml");
+    LocalOzoneClusterConfig reconOnly = 
LocalOzoneClusterConfig.builder(tempDir.resolve("local-ozone"))
+        .setS3gEnabled(false)
+        .setReconEnabled(true)
+        .build();
+
+    if (policy.isHttpEnabled()) {
+      try (LocalOzoneCluster cluster = new LocalOzoneCluster(reconOnly, seed)) 
{
+        cluster.prepareConfiguration();
+      }
+    } else {
+      IOException error = assertPrepareFails(reconOnly, seed);
+      assertMessageContains(error, OZONE_HTTP_POLICY_KEY);
+      assertMessageContains(error, "test-ozone-site.xml");
+    }
+  }
+
+  @Test
+  void formatNeverRejectsPortStateMissingReconPorts() throws Exception {
+    Path dataDir = tempDir.resolve("local-ozone");
+    prepare(LocalOzoneClusterConfig.builder(dataDir).build());
+    LocalOzoneClusterConfig config = LocalOzoneClusterConfig.builder(dataDir)
+        .setFormatMode(LocalOzoneClusterConfig.FormatMode.NEVER)
+        .setReconEnabled(true)
+        .build();
+
+    IOException error = assertPrepareFails(config);
+
+    assertMessageContains(error, "recon.");
+  }
+
   @Test
   void formatNeverRejectsPortStateMissingS3GatewayPorts() throws Exception {
     Path dataDir = tempDir.resolve("local-ozone");
@@ -783,6 +933,46 @@ void getDatanodeCountReturnsZeroBeforeStart() throws 
Exception {
     }
   }
 
+  @Test
+  void executeWithinStartupTimeoutReturnsStartupResult() throws Exception {
+    assertEquals(0, LocalOzoneCluster.executeWithinStartupTimeout("Recon", () 
-> 0,
+        () -> { }, Duration.ofSeconds(30)));
+  }
+
+  @Test
+  void executeWithinStartupTimeoutFailsFastWhenStartupBlocks() throws 
Exception {
+    CountDownLatch interrupted = new CountDownLatch(1);
+
+    IOException error =
+        assertThrows(IOException.class, () -> 
LocalOzoneCluster.executeWithinStartupTimeout(
+            "Recon", () -> {
+              try {
+                Thread.sleep(TimeUnit.MINUTES.toMillis(5));
+              } catch (InterruptedException e) {
+                interrupted.countDown();
+                Thread.currentThread().interrupt();
+              }
+              return 0;
+            }, () -> { }, Duration.ofMillis(200)));
+
+    assertMessageContains(error, "did not start within");
+    assertTrue(interrupted.await(30, TimeUnit.SECONDS),
+        "blocked startup should be interrupted on timeout");
+  }
+
+  @Test
+  void executeWithinStartupTimeoutPropagatesStartupFailure() {
+    AtomicBoolean cleanedUp = new AtomicBoolean();
+    IOException error =
+        assertThrows(IOException.class, () -> 
LocalOzoneCluster.executeWithinStartupTimeout(
+            "Recon", () -> {
+              throw new IOException("startup boom");
+            }, () -> cleanedUp.set(true), Duration.ofSeconds(30)));
+
+    assertMessageContains(error, "startup boom");
+    assertTrue(cleanedUp.get(), "a failed startup should run its cleanup");
+  }
+
   private LocalOzoneCluster.PreparedConfiguration prepare(
       LocalOzoneClusterConfig config) throws IOException {
     try (LocalOzoneCluster cluster = newCluster(config)) {
diff --git 
a/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneClusterConfig.java
 
b/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneClusterConfig.java
index 1f9fddbb965..845249fb3f3 100644
--- 
a/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneClusterConfig.java
+++ 
b/hadoop-ozone/tools/src/test/java/org/apache/hadoop/ozone/local/TestLocalOzoneClusterConfig.java
@@ -46,6 +46,8 @@ void builderProvidesLocalClusterDefaults() {
     assertEquals(0, config.getOmPort());
     assertEquals(0, config.getS3gPort());
     assertTrue(config.isS3gEnabled());
+    assertEquals(0, config.getReconPort());
+    assertFalse(config.isReconEnabled());
     assertFalse(config.isEphemeral());
     assertEquals(Duration.ofMinutes(2), config.getStartupTimeout());
   }
@@ -63,6 +65,9 @@ void typedDefaultsMatchSharedFallbackValues() {
     assertEquals(Boolean.parseBoolean(
         LocalOzoneClusterConfig.DEFAULT_S3G_ENABLED_VALUE),
         LocalOzoneClusterConfig.DEFAULT_S3G_ENABLED);
+    assertEquals(Boolean.parseBoolean(
+        LocalOzoneClusterConfig.DEFAULT_RECON_ENABLED_VALUE),
+        LocalOzoneClusterConfig.DEFAULT_RECON_ENABLED);
     assertEquals(Boolean.parseBoolean(
         LocalOzoneClusterConfig.DEFAULT_EPHEMERAL_VALUE),
         LocalOzoneClusterConfig.DEFAULT_EPHEMERAL);
@@ -83,6 +88,8 @@ void builderAcceptsExplicitOverrides() {
         .setOmPort(9862)
         .setS3gPort(9878)
         .setS3gEnabled(false)
+        .setReconPort(9888)
+        .setReconEnabled(true)
         .setEphemeral(true)
         .setStartupTimeout(Duration.ofSeconds(45))
         .build();
@@ -98,6 +105,8 @@ void builderAcceptsExplicitOverrides() {
     assertEquals(9862, config.getOmPort());
     assertEquals(9878, config.getS3gPort());
     assertFalse(config.isS3gEnabled());
+    assertEquals(9888, config.getReconPort());
+    assertTrue(config.isReconEnabled());
     assertTrue(config.isEphemeral());
     assertEquals(Duration.ofSeconds(45), config.getStartupTimeout());
   }
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 9427be1d502..1efaa89615e 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
@@ -127,6 +127,40 @@ void runCommandStartsRuntimeAndPrintsStartupSummary() 
throws Exception {
     assertTrue(text.contains("Press Ctrl+C to stop."), text);
   }
 
+  @Test
+  void runCommandPrintsReconEndpointWhenEnabled() throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    StubRuntime runtime = new StubRuntime("localhost", 9860, 9862,
+        "http://localhost:9878";);
+    runtime.reconEndpoint = "http://localhost:9888";;
+    TestableRunCommand command = new TestableRunCommand(runtime);
+    CommandLine commandLine = new CommandLine(command);
+    commandLine.setOut(new PrintWriter(new OutputStreamWriter(out, UTF_8),
+        true));
+
+    int exitCode = commandLine.execute("--recon");
+
+    assertEquals(0, exitCode);
+    String text = out.toString(UTF_8.name());
+    assertTrue(text.contains("Recon endpoint: http://localhost:9888";), text);
+  }
+
+  @Test
+  void runCommandOmitsReconEndpointWhenDisabled() throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    StubRuntime runtime = new StubRuntime("localhost", 9860, 9862,
+        "http://localhost:9878";);
+    TestableRunCommand command = new TestableRunCommand(runtime);
+    CommandLine commandLine = new CommandLine(command);
+    commandLine.setOut(new PrintWriter(new OutputStreamWriter(out, UTF_8),
+        true));
+
+    int exitCode = commandLine.execute();
+
+    assertEquals(0, exitCode);
+    assertFalse(out.toString(UTF_8.name()).contains("Recon endpoint:"));
+  }
+
   @Test
   void runCommandOmitsS3SummaryWhenS3gDisabled() throws Exception {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -277,6 +311,10 @@ void runCommandOptionsUseEnvironmentDefaults() throws 
Exception {
         LocalOzoneClusterConfig.DEFAULT_S3G_ENABLED_VALUE);
     assertEnvDefault("s3gPort", OzoneLocal.ENV_S3G_PORT,
         LocalOzoneClusterConfig.DEFAULT_PORT_VALUE);
+    assertEnvDefault("reconEnabled", OzoneLocal.ENV_RECON_ENABLED,
+        LocalOzoneClusterConfig.DEFAULT_RECON_ENABLED_VALUE);
+    assertEnvDefault("reconPort", OzoneLocal.ENV_RECON_PORT,
+        LocalOzoneClusterConfig.DEFAULT_PORT_VALUE);
     assertEnvDefault("ephemeral", OzoneLocal.ENV_EPHEMERAL,
         LocalOzoneClusterConfig.DEFAULT_EPHEMERAL_VALUE);
     assertEnvDefault("startupTimeout", OzoneLocal.ENV_STARTUP_TIMEOUT,
@@ -298,6 +336,8 @@ void resolveConfigUsesPicocliDefaults() {
     assertEquals(0, config.getOmPort());
     assertEquals(0, config.getS3gPort());
     assertTrue(config.isS3gEnabled());
+    assertEquals(0, config.getReconPort());
+    assertFalse(config.isReconEnabled());
     assertFalse(config.isEphemeral());
     assertEquals(Duration.ofMinutes(2), config.getStartupTimeout());
   }
@@ -319,6 +359,8 @@ void resolveConfigUsesCliOverrides() {
         "--om-port", "201",
         "--s3g-port", "202",
         "--no-s3g",
+        "--recon-port", "203",
+        "--recon",
         "--ephemeral",
         "--startup-timeout", "45s");
 
@@ -333,6 +375,8 @@ void resolveConfigUsesCliOverrides() {
     assertEquals(201, config.getOmPort());
     assertEquals(202, config.getS3gPort());
     assertFalse(config.isS3gEnabled());
+    assertEquals(203, config.getReconPort());
+    assertTrue(config.isReconEnabled());
     assertTrue(config.isEphemeral());
     assertEquals(Duration.ofSeconds(45), config.getStartupTimeout());
   }
@@ -352,6 +396,18 @@ void resolveConfigAllowsS3gAndEphemeralToBeNegated() {
     assertFalse(config.isEphemeral());
   }
 
+  @Test
+  void resolveConfigAllowsReconToBeNegated() {
+    LocalOzoneClusterConfig config = resolve("--no-recon");
+
+    assertFalse(config.isReconEnabled());
+  }
+
+  @Test
+  void resolveConfigRejectsInvalidReconPort() {
+    assertConfigError("--recon-port", "65536", "--recon-port");
+  }
+
   @Test
   void resolveConfigRejectsInvalidFormat() {
     assertParseError("--format", "sometimes", "sometimes");
@@ -499,6 +555,7 @@ private static final class StubRuntime implements 
LocalOzoneRuntime {
     private final int scmPort;
     private final int omPort;
     private final String s3Endpoint;
+    private String reconEndpoint = "";
     private boolean failStart;
     private boolean started;
     private boolean closed;
@@ -543,6 +600,16 @@ public String getS3Endpoint() {
       return s3Endpoint;
     }
 
+    @Override
+    public int getReconPort() {
+      return 0;
+    }
+
+    @Override
+    public String getReconEndpoint() {
+      return reconEndpoint;
+    }
+
     @Override
     public void close() {
       closed = true;
@@ -570,8 +637,12 @@ public String defaultValue(ArgSpec argSpec) {
           || "--om-port".equals(option)
           || "--s3g-port".equals(option)) {
         return LocalOzoneClusterConfig.DEFAULT_PORT_VALUE;
+      } else if ("--recon-port".equals(option)) {
+        return LocalOzoneClusterConfig.DEFAULT_PORT_VALUE;
       } else if ("--s3g".equals(option)) {
         return LocalOzoneClusterConfig.DEFAULT_S3G_ENABLED_VALUE;
+      } else if ("--recon".equals(option)) {
+        return LocalOzoneClusterConfig.DEFAULT_RECON_ENABLED_VALUE;
       } else if ("--ephemeral".equals(option)) {
         return LocalOzoneClusterConfig.DEFAULT_EPHEMERAL_VALUE;
       } else if ("--startup-timeout".equals(option)) {


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

Reply via email to