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

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


The following commit(s) were added to refs/heads/master by this push:
     new 39f4f8565 fix(dist): gate init-store on a dedicated init_store.enabled 
option (#3119)
39f4f8565 is described below

commit 39f4f8565a7d02c58d9fd33a2675bbe44c4f92ab
Author: KAI <[email protected]>
AuthorDate: Wed Aug 5 19:27:33 2026 +0530

    fix(dist): gate init-store on a dedicated init_store.enabled option (#3119)
    
    TODO: we need handle `usePD` in future
    
    ---------
    
    Co-authored-by: imbajin <[email protected]>
---
 .github/workflows/docker-build-ci.yml              |   5 +
 docker/README.md                                   |  27 +-
 .../org/apache/hugegraph/config/ServerOptions.java |  11 +
 .../org/apache/hugegraph/core/GraphManager.java    |  41 +-
 .../hugegraph-dist/docker/docker-entrypoint.sh     |  59 ++-
 .../docker/test/test-docker-entrypoint.sh          |  68 +++
 .../src/assembly/static/bin/init-store.sh          |   4 +
 .../java/org/apache/hugegraph/cmd/InitStore.java   | 202 ++++++++-
 .../org/apache/hugegraph/unit/UnitTestSuite.java   |   6 +
 .../hugegraph/unit/cmd/InitStoreConfigTest.java    | 486 +++++++++++++++++++++
 .../unit/core/GraphManagerAdminInitTest.java       | 166 +++++++
 11 files changed, 1059 insertions(+), 16 deletions(-)

diff --git a/.github/workflows/docker-build-ci.yml 
b/.github/workflows/docker-build-ci.yml
index eb1f0d985..ada012be8 100644
--- a/.github/workflows/docker-build-ci.yml
+++ b/.github/workflows/docker-build-ci.yml
@@ -26,6 +26,7 @@ on:
     paths:
       - '**/Dockerfile*'
       - '.dockerignore'
+      - 'hugegraph-server/hugegraph-dist/docker/**'
       - 'hugegraph-server/hugegraph-dist/src/assembly/static/bin/util.sh'
 
 jobs:
@@ -53,6 +54,10 @@ jobs:
           echo "Healthcheck: $HC"
           [[ "$HC" != "null" ]] || { echo "ERROR: HEALTHCHECK missing in ${{ 
matrix.dockerfile }}"; exit 1; }
 
+      - name: Test server entrypoint property mapping
+        if: matrix.dockerfile == 'hugegraph-server/Dockerfile'
+        run: bash 
hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh
+
       # The startup preflight needs a socket-table tool, and the base image
       # ships none of its own.  Without one every start reports "unknown" and
       # a duplicate start is no longer refused, so assert the image can
diff --git a/docker/README.md b/docker/README.md
index 9bc21b1ba..e55879874 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -160,7 +160,32 @@ Configuration is injected via environment variables. The 
old `docker/configs/app
 | `HG_SERVER_BACKEND` | Yes | — | `backend` in `hugegraph.properties` | 
Storage backend (e.g. `hstore`) |
 | `HG_SERVER_PD_PEERS` | Yes | — | `pd.peers` | PD cluster addresses (e.g. 
`pd0:8686,pd1:8686,pd2:8686`) |
 | `STORE_REST` | No | — | Used by `wait-partition.sh` | Store REST endpoint 
for partition verification (e.g. `store0:8520`) |
-| `PASSWORD` | No | — | Enables auth mode | Optional authentication password |
+| `PASSWORD` | No | — | Enables auth mode | Optional authentication password; 
ignored when `HG_SERVER_INIT_STORE_ENABLED` is `false` (see below) |
+| `HG_SERVER_INIT_STORE_ENABLED` | No | `true` | `init_store.enabled` in 
`rest-server.properties` | Set `false` in PD/HStore deployments so init-store 
skips local backend and admin initialization |
+
+> **The built-in authenticator with `HG_SERVER_INIT_STORE_ENABLED=false`
+> requires `usePD=true` and an HStore-backed `auth.graph_store`, unless
+> `auth.remote_url` delegates auth elsewhere.** With init-store skipped, the
+> server creates the built-in admin in PD metadata, and only an HStore auth
+> graph uses the PD-backed auth manager that can read that account. init-store
+> exits non-zero when the combination is unusable, rather than leaving a server
+> nobody can log in to. A custom `auth.authenticator` is exempt because it
+> manages its own identities.
+>
+> `docker/init_complete` is written by init-store itself, and only after it has
+> initialized. A skipped run therefore records nothing, whether it was disabled
+> by the variable or by the property in a mounted `rest-server.properties`, so 
a
+> later re-enable is still able to initialize. The marker only short-circuits
+> re-initialization: init-store runs on every container start, and a disabled
+> one performs the fail-closed check above first, so a marker left by an
+> earlier release or an earlier enabled run cannot bypass it.
+>
+> **`PASSWORD` does not reach that path.** init-store reads it from standard
+> input, and a disabled one returns before doing so. The admin is instead
+> created from `auth.admin_pa`, whose `pa` default is public, so init-store
+> refuses to skip unless it is explicitly set to a non-empty value in a mounted
+> `rest-server.properties`. It applies only when the account is first created,
+> so changing it later does not rotate an existing password.
 
 **Deprecated aliases** (still work but log a warning):
 
diff --git 
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
 
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
index 7d706de8f..4f59a79b5 100644
--- 
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
+++ 
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/config/ServerOptions.java
@@ -368,6 +368,17 @@ public class ServerOptions extends OptionHolder {
                     "./conf/graphs"
             );
 
+    public static final ConfigOption<Boolean> INIT_STORE_ENABLED =
+            new ConfigOption<>(
+                    "init_store.enabled",
+                    "Whether init-store initializes the local backend stores " 
+
+                    "and the built-in admin account. Set false in distributed 
" +
+                    "deployments (PD/HStore) where the storage side already " +
+                    "owns the metadata.",
+                    disallowEmpty(),
+                    true
+            );
+
     public static final ConfigOption<Boolean> 
SERVER_START_IGNORE_SINGLE_GRAPH_ERROR =
             new ConfigOption<>(
                     "server.start_ignore_single_graph_error",
diff --git 
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java
 
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java
index f716285c6..40d65c928 100644
--- 
a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java
+++ 
b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/core/GraphManager.java
@@ -355,7 +355,10 @@ public final class GraphManager {
 
         this.initMetaManager(conf);
         this.initK8sManagerIfNeeded(conf);
-        this.initAdminUserIfNeeded(conf.get(ServerOptions.ADMIN_PA));
+        if (shouldBootstrapAdmin(this.authenticator,
+                                 conf.get(ServerOptions.AUTH_REMOTE_URL))) {
+            this.initAdminUserIfNeeded(conf.get(ServerOptions.ADMIN_PA));
+        }
 
         this.createDefaultGraphSpaceIfNeeded(conf);
 
@@ -368,6 +371,19 @@ public final class GraphManager {
         this.listenMetaChanges();
     }
 
+    private static boolean shouldBootstrapAdmin(HugeAuthenticator 
authenticator,
+                                                String remoteUrl) {
+        return authenticator instanceof StandardAuthenticator &&
+               remoteUrl.isEmpty();
+    }
+
+    /**
+     * Creates the built-in admin account in PD metadata. With init-store
+     * disabled this is the only bootstrap that admin gets, and init-store's
+     * fail-closed check assumes it works, so only the already-exists case is
+     * benign; any other failure aborts startup instead of leaving the server
+     * without a usable administrator.
+     */
     public void initAdminUserIfNeeded(String password) {
         HugeUser user = new HugeUser("admin");
         user.nickname("超级管理员");
@@ -380,10 +396,29 @@ public final class GraphManager {
         user.create(new Date());
         user.avatar("/image.png");
         try {
-            this.metaManager.createUser(user);
+            try {
+                this.metaManager.createUser(user);
+            } catch (Exception e) {
+                // Judged by re-reading rather than by matching the message:
+                // benign only if the admin actually exists, from an earlier
+                // startup or from a concurrent server that won the race
+                HugeUser existing;
+                try {
+                    existing = this.metaManager.findUser(user.name());
+                } catch (Exception probe) {
+                    e.addSuppressed(probe);
+                    throw e;
+                }
+                if (existing == null) {
+                    throw e;
+                }
+                LOG.info("The built-in admin user already exists, " +
+                         "skip creating it");
+            }
             this.metaManager.initDefaultGraphSpace();
         } catch (Exception e) {
-            LOG.info(e.getMessage());
+            throw new HugeException("Failed to init the built-in admin " +
+                                    "user or the default graph space", e);
         }
     }
 
diff --git a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh 
b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh
index 2c76a5443..11d2c460e 100755
--- a/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh
+++ b/hugegraph-server/hugegraph-dist/docker/docker-entrypoint.sh
@@ -20,6 +20,7 @@ set -euo pipefail
 DOCKER_FOLDER="./docker"
 INIT_FLAG_FILE="init_complete"
 GRAPH_CONF="./conf/graphs/hugegraph.properties"
+REST_SERVER_CONF="./conf/rest-server.properties"
 
 mkdir -p "${DOCKER_FOLDER}"
 
@@ -27,13 +28,15 @@ log() { echo "[hugegraph-server-entrypoint] $*"; }
 
 set_prop() {
     local key="$1" val="$2" file="$3"
-    local esc_key esc_val
+    local esc_key esc_val key_re
 
     esc_key=$(printf '%s' "$key" | sed -e 's/[][(){}.^$*+?|\\/]/\\&/g')
-    esc_val=$(printf '%s' "$val" | sed -e 's/[&|\\]/\\&/g')
+    esc_val=$(printf '%s' "$val" | sed -e 's/[&|\\~]/\\&/g')
+    
key_re="^[[:space:]]*${esc_key}([[:space:]]*[:=]|[[:space:]]+|[[:space:]]*$)"
 
-    if grep -qE "^[[:space:]]*${esc_key}[[:space:]]*=" "${file}"; then
-        sed -ri "s|^([[:space:]]*${esc_key}[[:space:]]*=).*|\\1${esc_val}|" 
"${file}"
+    if grep -qE "${key_re}" "${file}"; then
+        sed -ri "0,/${key_re}/!{/${key_re}/d;}" "${file}"
+        sed -ri "0,/${key_re}/s~${key_re}.*~${key}=${esc_val}~" "${file}"
     else
         printf '%s=%s\n' "$key" "$val" >> "${file}"
     fi
@@ -55,13 +58,39 @@ migrate_env "PD_PEERS" "HG_SERVER_PD_PEERS"
 [[ -n "${HG_SERVER_BACKEND:-}"  ]] && set_prop "backend"  
"${HG_SERVER_BACKEND}"  "${GRAPH_CONF}"
 [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && set_prop "pd.peers" 
"${HG_SERVER_PD_PEERS}" "${GRAPH_CONF}"
 
+# Normalized once here and reused by the init-flag guard below. The accepted
+# spellings are the ones HugeConfig accepts, case-insensitive: commons-lang 2.x
+# BooleanUtils, reached through commons-configuration 1.x PropertyConverter.
+# That set excludes 0 and 1, which commons-lang3 would have taken. Anything
+# outside it is rejected now rather than touching the init flag for a value the
+# server is going to refuse anyway.
+INIT_STORE_ENABLED=$(printf '%s' "${HG_SERVER_INIT_STORE_ENABLED:-}" |
+                     tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
+case "${INIT_STORE_ENABLED}" in
+    "" | y | t | yes | on | true | n | f | no | off | false) ;;
+    *) log "ERROR: invalid HG_SERVER_INIT_STORE_ENABLED" \
+           "'${HG_SERVER_INIT_STORE_ENABLED}'"
+       exit 1 ;;
+esac
+[[ -n "${INIT_STORE_ENABLED}" ]] && \
+    set_prop "init_store.enabled" "${INIT_STORE_ENABLED}" "${REST_SERVER_CONF}"
+
 # ── Build wait-storage env ─────────────────────────────────────────────
 WAIT_ENV=()
 [[ -n "${HG_SERVER_BACKEND:-}"  ]] && 
WAIT_ENV+=("hugegraph.backend=${HG_SERVER_BACKEND}")
 [[ -n "${HG_SERVER_PD_PEERS:-}" ]] && 
WAIT_ENV+=("hugegraph.pd.peers=${HG_SERVER_PD_PEERS}")
 
-# ── Init store (once) ─────────────────────────────────────────────────
-if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then
+# ── Init store ────────────────────────────────────────────────────────
+# init-store owns the marker: it skips re-initialization when the marker is
+# present and writes it only after it has actually initialized. Deciding here
+# would mean guessing from the environment variable, which says nothing about
+# a config mounted with the property already set. Absolute, so the in-Java
+# existence check agrees with the guard below no matter where init-store.sh
+# leaves its working directory.
+INIT_MARKER_PATH="$(cd "${DOCKER_FOLDER}" && pwd)/${INIT_FLAG_FILE}"
+export HG_SERVER_INIT_COMPLETE_MARKER="${INIT_MARKER_PATH}"
+
+if [[ ! -f "${INIT_MARKER_PATH}" ]]; then
     if (( ${#WAIT_ENV[@]} > 0 )); then
         env "${WAIT_ENV[@]}" ./bin/wait-storage.sh
     else
@@ -74,11 +103,25 @@ if [[ ! -f "${DOCKER_FOLDER}/${INIT_FLAG_FILE}" ]]; then
     else
         log "init hugegraph with auth mode"
         ./bin/enable-auth.sh
+        # init-store reads the password from stdin, and a disabled one returns
+        # before it gets there, so say plainly that PASSWORD is being dropped
+        case "${INIT_STORE_ENABLED}" in
+            n | f | no | off | false)
+                log "WARN: PASSWORD is ignored while init-store is disabled;" \
+                    "the admin is created on the PD startup path from" \
+                    "'auth.admin_pa', which defaults to the public value 'pa'" 
;;
+        esac
         echo "${PASSWORD}" | ./bin/init-store.sh
     fi
-    touch "${DOCKER_FOLDER}/${INIT_FLAG_FILE}"
 else
-    log "HugeGraph initialization already done. Skipping re-init..."
+    log "HugeGraph initialization already done. Revalidating the config..."
+    # The marker skips re-initialization inside init-store, not init-store
+    # itself: a disabled one must pass its fail-closed check on every startup,
+    # because the marker may predate this configuration or this release and
+    # says nothing about whether the admin the current config relies on is
+    # reachable. An enabled one returns at the marker, before it touches the
+    # backend or reads stdin, so neither wait-storage nor PASSWORD is needed.
+    ./bin/init-store.sh
 fi
 
 ./bin/start-hugegraph.sh -j "${JAVA_OPTS:-}" -t 120
diff --git 
a/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh 
b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh
new file mode 100644
index 000000000..1279da555
--- /dev/null
+++ b/hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+#
+# 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.
+
+set -euo pipefail
+
+entrypoint="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && 
pwd)/docker-entrypoint.sh"
+test_dir="$(mktemp -d)"
+trap 'rm -rf "${test_dir}"' EXIT
+
+eval "$(awk '
+    /^set_prop\(\) \{/ { capture = 1 }
+    capture { print }
+    capture && /^\}$/ { exit }
+' "${entrypoint}")"
+
+assert_replaced() {
+    local separator="$1"
+    local file="${test_dir}/config-${separator// /space}"
+
+    printf 'init_store.enabled%sfalse\n' "${separator}" > "${file}"
+    set_prop "init_store.enabled" "true" "${file}"
+    [[ "$(grep -Ec '^init_store\.enabled=true$' "${file}")" -eq 1 ]]
+}
+
+assert_line_count() {
+    local expected="$1" pattern="$2" file="$3"
+    local actual
+
+    actual=$(grep -Ec "${pattern}" "${file}")
+    if [[ "${actual}" -ne "${expected}" ]]; then
+        echo "expected ${expected} matching lines, got ${actual}" >&2
+        return 1
+    fi
+}
+
+assert_replaced "="
+assert_replaced ": "
+assert_replaced " "
+
+duplicate_file="${test_dir}/config-duplicates"
+printf '%s\n' \
+    'init_store.enabled=false' \
+    'init_store.enabled: false' \
+    'init_store.enabled false' \
+    'init_store.enabled' \
+    'unrelated=true' > "${duplicate_file}"
+set_prop "init_store.enabled" "true" "${duplicate_file}"
+assert_line_count 1 \
+    
'^[[:space:]]*init_store\.enabled([[:space:]]*[:=]|[[:space:]]+|[[:space:]]*$)' 
\
+    "${duplicate_file}"
+assert_line_count 1 '^init_store\.enabled=true$' "${duplicate_file}"
+grep -q '^unrelated=true$' "${duplicate_file}"
diff --git 
a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh 
b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh
index b9da84166..74ec0bb73 100755
--- a/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh
+++ b/hugegraph-server/hugegraph-dist/src/assembly/static/bin/init-store.sh
@@ -56,5 +56,9 @@ CP="$CP":$(find -L "${LIB}" -name '*.jar' \! -name 
'hugegraph*' | sort | tr '\n'
 CP="$CP":$(find -L "${PLUGINS}" -name '*.jar' | sort | tr '\n' ':')
 $JAVA -cp $CP ${DEFAULT_JAVA_OPTIONS} \
 org.apache.hugegraph.cmd.InitStore "${CONF}"/rest-server.properties
+INIT_STORE_STATUS=$?
+if [[ ${INIT_STORE_STATUS} -ne 0 ]]; then
+    exit "${INIT_STORE_STATUS}"
+fi
 
 echo "Initialization finished."
diff --git 
a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java
 
b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java
index 4391edb43..5362a1821 100644
--- 
a/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java
+++ 
b/hugegraph-server/hugegraph-dist/src/main/java/org/apache/hugegraph/cmd/InitStore.java
@@ -17,10 +17,15 @@
 
 package org.apache.hugegraph.cmd;
 
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 
 import org.apache.hugegraph.HugeFactory;
 import org.apache.hugegraph.HugeGraph;
@@ -42,6 +47,19 @@ public class InitStore {
 
     private static final Logger LOG = Log.logger(InitStore.class);
 
+    /**
+     * Where to record that initialization actually happened. The caller that
+     * wants the record supplies the path; nothing is read or written when it
+     * is unset, so tarball callers are unaffected. A present marker skips
+     * re-initialization only: the disabled path's fail-closed check runs
+     * before it is consulted, since a marker left by an earlier release or an
+     * earlier enabled run says nothing about the current configuration.
+     */
+    public static final String INIT_COMPLETE_MARKER =
+                               "hugegraph.init_complete_marker";
+    private static final String INIT_COMPLETE_MARKER_ENV =
+                                "HG_SERVER_INIT_COMPLETE_MARKER";
+
     public static void main(String[] args) throws Exception {
         E.checkArgument(args.length == 1,
                         "HugeGraph init-store need to pass the config file " +
@@ -51,11 +69,39 @@ public class InitStore {
 
         String restConf = args[0];
 
-        RegisterUtil.registerBackends();
-        RegisterUtil.registerPlugins();
+        // Server options alone can answer the gate below. Backend and plugin
+        // registration waits for the enabled path, because registerPlugins()
+        // runs every plugin's register() and propagates its failures.
         RegisterUtil.registerServer();
 
         HugeConfig restServerConfig = new HugeConfig(restConf);
+
+        /*
+         * PD/HStore deployments let the storage side own the metadata, so
+         * init-store has nothing to do; on Kubernetes it re-ran on every 
Server
+         * pod restart, since the entrypoint's flag file does not survive one.
+         * Skipping also skips creating the built-in admin, which only the PD
+         * startup path can replace, and only for a PD-backed HStore auth 
graph.
+         */
+        if (!restServerConfig.get(ServerOptions.INIT_STORE_ENABLED)) {
+            LOG.warn("Skipping init-store: '{}' is false in '{}'. Local " +
+                     "backend and admin initialization are not performed.",
+                     ServerOptions.INIT_STORE_ENABLED.name(), restConf);
+            checkAdminBootstrapReachable(restServerConfig, restConf);
+            return;
+        }
+
+        String initedMarker = presentInitCompleteMarker();
+        if (initedMarker != null) {
+            LOG.info("Skipping init-store: completion marker '{}' is " +
+                     "present, so this deployment is already initialized",
+                     initedMarker);
+            return;
+        }
+
+        RegisterUtil.registerBackends();
+        RegisterUtil.registerPlugins();
+
         PDAuthConfig.setAuthority(
                 ServiceConstant.SERVICE_NAME,
                 ServiceConstant.AUTHORITY);
@@ -68,7 +114,7 @@ public class InitStore {
             for (Map.Entry<String, String> entry : 
graph2ConfigPaths.entrySet()) {
                 String configPath = entry.getValue();
                 HugeConfig config = new HugeConfig(configPath);
-                if (Objects.equals(config.get(CoreOptions.BACKEND), "hstore")) 
{
+                if (isHstoreBackend(config.get(CoreOptions.BACKEND))) {
                     // skip initializing hstore backend
                     continue;
                 }
@@ -81,6 +127,154 @@ public class InitStore {
             }
             HugeFactory.shutdown(30L, true);
         }
+
+        recordInitComplete();
+    }
+
+    private static String configuredInitCompleteMarker() {
+        String marker = System.getProperty(INIT_COMPLETE_MARKER,
+                                           
System.getenv(INIT_COMPLETE_MARKER_ENV));
+        return marker == null || marker.isEmpty() ? null : marker;
+    }
+
+    /**
+     * The configured marker path, or null when none is configured or the
+     * file does not exist yet. Consulted only after the disabled-path check,
+     * so an existing marker can never bypass the fail-closed validation.
+     */
+    private static String presentInitCompleteMarker() {
+        String marker = configuredInitCompleteMarker();
+        if (marker == null) {
+            return null;
+        }
+        Path path = Paths.get(marker);
+        if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
+            return marker;
+        }
+        if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
+            throw invalidInitCompleteMarker(path);
+        }
+        return null;
+    }
+
+    /**
+     * Only this process knows whether it initialized anything. The Docker
+     * entrypoint used to decide from its environment variable alone, so a
+     * mounted config that disabled init-store was still recorded as done and a
+     * later re-enable skipped for good. Reached only on the enabled path, and
+     * only after initialization succeeded.
+     */
+    private static void recordInitComplete() throws IOException {
+        String marker = configuredInitCompleteMarker();
+        if (marker == null) {
+            return;
+        }
+        Path path = Paths.get(marker);
+        Path dir = path.toAbsolutePath().getParent();
+        if (dir != null) {
+            Files.createDirectories(dir);
+        }
+        try {
+            Files.createFile(path);
+        } catch (FileAlreadyExistsException e) {
+            // A concurrent container finishing its own successful init has
+            // already recorded it, which is the same outcome
+            if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
+                throw invalidInitCompleteMarker(path);
+            }
+        }
+        LOG.info("Recorded init-store completion at '{}'", path);
+    }
+
+    private static IllegalStateException invalidInitCompleteMarker(Path path) {
+        return new IllegalStateException(String.format(
+                "Init-store completion marker '%s' must be a regular file",
+                path));
+    }
+
+    /**
+     * Skipping leaves the built-in admin to 
GraphManager.initAdminUserIfNeeded()
+     * on the PD startup path, which writes it to PD metadata. Only an HStore
+     * auth graph reads that metadata back, so every other local built-in-auth
+     * configuration would start a server nobody can log in to. Remote auth and
+     * custom authenticators keep their identities elsewhere and are exempt.
+     */
+    private static void checkAdminBootstrapReachable(HugeConfig conf,
+                                                     String restConf) {
+        if (!requiresLocalBuiltinAdmin(conf)) {
+            return;
+        }
+        if (!conf.get(ServerOptions.USE_PD)) {
+            throw unreachableAdmin(restConf, ServerOptions.USE_PD.name() +
+                                             " is false");
+        }
+
+        String name = conf.get(ServerOptions.AUTH_GRAPH_STORE);
+        String path = ConfigUtil.scanGraphsDir(
+                conf.get(ServerOptions.GRAPHS)).get(name);
+        if (path == null) {
+            throw unreachableAdmin(restConf, "auth graph '" + name +
+                                             "' has no local configuration");
+        }
+        String backend = new HugeConfig(path).get(CoreOptions.BACKEND);
+        if (!isHstoreBackend(backend)) {
+            throw unreachableAdmin(restConf, "auth graph '" + name +
+                                             "' uses backend '" + backend +
+                                             "', not 'hstore'");
+        }
+
+        // The server creates the admin from this value and cannot prompt for
+        // it, and Docker PASSWORD never reaches this path. An absent or empty
+        // one would hand out the public 'pa' default, so fail instead. Checked
+        // with containsKey because the default is not a configured secret.
+        if (!conf.containsKey(ServerOptions.ADMIN_PA.name()) ||
+            conf.get(ServerOptions.ADMIN_PA).isEmpty()) {
+            throw unreachableAdmin(restConf, "no explicit non-empty '" +
+                                             ServerOptions.ADMIN_PA.name() +
+                                             "' is configured, so the admin " +
+                                             "would be created with the " +
+                                             "public default");
+        }
+    }
+
+    private static IllegalStateException unreachableAdmin(String restConf,
+                                                          String reason) {
+        return new IllegalStateException(String.format(
+                "Refusing to skip init-store: '%s' configures the built-in " +
+                "authenticator but %s, so the admin created on the PD startup 
" +
+                "path would be unreachable. See docker/README.md.",
+                restConf, reason));
+    }
+
+    private static boolean isHstoreBackend(String backend) {
+        return "hstore".equalsIgnoreCase(backend);
+    }
+
+    /**
+     * HugeAuthenticator.loadAuthenticator() accepts any implementation class,
+     * and only StandardAuthenticator bootstraps HugeGraph's built-in admin
+     * account. A custom one (LDAP, OIDC, a plugin) manages its identities
+     * elsewhere, so it must not be held to the requirement above. The class is
+     * resolved without initializing it, and one that is not on the init-store
+     * classpath is by definition not the built-in authenticator.
+     */
+    private static boolean requiresLocalBuiltinAdmin(HugeConfig conf) {
+        if (!conf.get(ServerOptions.AUTH_REMOTE_URL).isEmpty()) {
+            return false;
+        }
+        String authClass = conf.get(ServerOptions.AUTHENTICATOR);
+        if (authClass.isEmpty()) {
+            return false;
+        }
+        try {
+            Class<?> clazz = Class.forName(authClass, false,
+                                           InitStore.class.getClassLoader());
+            return StandardAuthenticator.class.isAssignableFrom(clazz);
+        } catch (ClassNotFoundException | LinkageError e) {
+            LOG.info("Authenticator '{}' is not on the init-store classpath, " 
+
+                     "so it is not the built-in one", authClass);
+            return false;
+        }
     }
 
     private static HugeGraph initGraph(String configPath) throws Exception {
diff --git 
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
index 1d3dd58a8..1733680e3 100644
--- 
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
+++ 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java
@@ -37,6 +37,7 @@ import org.apache.hugegraph.unit.cache.CacheTest;
 import org.apache.hugegraph.unit.cache.CachedGraphTransactionTest;
 import org.apache.hugegraph.unit.cache.CachedSchemaTransactionTest;
 import org.apache.hugegraph.unit.cache.RamTableTest;
+import org.apache.hugegraph.unit.cmd.InitStoreConfigTest;
 import org.apache.hugegraph.unit.core.AnalyzerTest;
 import org.apache.hugegraph.unit.core.BackendMutationTest;
 import org.apache.hugegraph.unit.core.BackendStoreInfoTest;
@@ -45,6 +46,7 @@ import org.apache.hugegraph.unit.core.ConditionTest;
 import org.apache.hugegraph.unit.core.DataTypeTest;
 import org.apache.hugegraph.unit.core.DirectionsTest;
 import org.apache.hugegraph.unit.core.ExceptionTest;
+import org.apache.hugegraph.unit.core.GraphManagerAdminInitTest;
 import org.apache.hugegraph.unit.core.GraphManagerConfigTest;
 import org.apache.hugegraph.unit.core.LocksTableTest;
 import org.apache.hugegraph.unit.core.PageStateTest;
@@ -143,6 +145,7 @@ import org.junit.runners.Suite;
         SecurityManagerTest.class,
         RolePermissionTest.class,
         ExceptionTest.class,
+        GraphManagerAdminInitTest.class,
         GraphManagerConfigTest.class,
         BackendStoreInfoTest.class,
         TraversalUtilTest.class,
@@ -155,6 +158,9 @@ import org.junit.runners.Suite;
         HugeGraphAuthProxyTest.class,
         SchemaElementTest.class,
 
+        /* cmd */
+        InitStoreConfigTest.class,
+
         /* serializer */
         BytesBufferTest.class,
         SerializerFactoryTest.class,
diff --git 
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java
 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java
new file mode 100644
index 000000000..ba88ec722
--- /dev/null
+++ 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cmd/InitStoreConfigTest.java
@@ -0,0 +1,486 @@
+/*
+ * 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.hugegraph.unit.cmd;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.apache.hugegraph.auth.StandardAuthenticator;
+import org.apache.hugegraph.cmd.InitStore;
+import org.apache.hugegraph.config.ConfigException;
+import org.apache.hugegraph.config.CoreOptions;
+import org.apache.hugegraph.config.HugeConfig;
+import org.apache.hugegraph.config.OptionSpace;
+import org.apache.hugegraph.config.ServerOptions;
+import org.apache.hugegraph.dist.RegisterUtil;
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.apache.hugegraph.util.ConfigUtil;
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * {@code init_store.enabled} controls whether init-store performs local
+ * backend and admin initialization. It defaults to true, so standalone and
+ * tarball installs keep the full init path; distributed (PD/HStore)
+ * deployments set it to false.
+ */
+public class InitStoreConfigTest {
+
+    private static final String MISSING_GRAPHS_DIR = "no-such-graphs-dir";
+    // Registered by RegisterUtil.registerBackends(), not by registerServer()
+    private static final String ROCKSDB_OPTION = "rocksdb.data_path";
+
+    private Path workDir;
+    private int confSeq;
+
+    @BeforeClass
+    public static void registerOptions() {
+        RegisterUtil.registerServer();
+    }
+
+    @Before
+    public void setup() throws IOException {
+        this.workDir = Files.createTempDirectory("init-store-config-test");
+    }
+
+    @After
+    public void teardown() throws IOException {
+        try (Stream<Path> paths = Files.walk(this.workDir)) {
+            for (Path path : paths.sorted(Comparator.reverseOrder())
+                                  .toArray(Path[]::new)) {
+                Files.deleteIfExists(path);
+            }
+        }
+    }
+
+    @Test
+    public void testInitStoreEnabledByDefault() {
+        HugeConfig config = new HugeConfig(new PropertiesConfiguration());
+        Assert.assertTrue(config.get(ServerOptions.INIT_STORE_ENABLED));
+    }
+
+    /**
+     * Pins the spellings docker-entrypoint.sh may pass through. The shell side
+     * is not executed here, so keep the two lists in step by hand.
+     */
+    @Test
+    public void testBooleanSpellingsAcceptedByHugeConfig() {
+        for (String value : new String[]{"true", "TRUE", "t", "on", "y", 
"yes"}) {
+            Assert.assertTrue(value, initStoreEnabled(value));
+        }
+        for (String value : new String[]{"false", "FALSE", "f", "off", "n", 
"no"}) {
+            Assert.assertFalse(value, initStoreEnabled(value));
+        }
+    }
+
+    /**
+     * Conversion runs at load time via commons-configuration 1.x
+     * PropertyConverter, which delegates to commons-lang 2.x BooleanUtils —
+     * so "0" and "1" are refused here even though commons-lang3 accepts them.
+     */
+    @Test
+    public void testUnparseableBooleanFailsToLoad() {
+        for (String value : new String[]{"0", "1", "disabled"}) {
+            Assert.assertThrows(ConfigException.class, () -> {
+                initStoreEnabled(value);
+            });
+        }
+    }
+
+    private static boolean initStoreEnabled(String value) {
+        PropertiesConfiguration properties = new PropertiesConfiguration();
+        properties.setProperty(ServerOptions.INIT_STORE_ENABLED.name(), value);
+        return new 
HugeConfig(properties).get(ServerOptions.INIT_STORE_ENABLED);
+    }
+
+    /**
+     * A key defined twice loads as a list and fails the scalar type check, so
+     * a caller mapping an env var onto it must replace, not append.
+     */
+    @Test
+    public void testDuplicateDefinitionFailsToLoad() throws IOException {
+        Path conf = this.workDir.resolve("duplicate.properties");
+        String key = ServerOptions.INIT_STORE_ENABLED.name();
+        Files.write(conf, Arrays.asList(key + "=false", key + "=true"),
+                    StandardCharsets.UTF_8);
+
+        Assert.assertThrows(IllegalArgumentException.class, () -> {
+            new HugeConfig(conf.toString());
+        }, e -> Assert.assertContains("[false, true]", e.getMessage()));
+    }
+
+    /**
+     * Establishes that graph scanning fails for these configs, which is what
+     * keeps {@link #testDisabledInitStoreExitsBeforeGraphInit()} honest.
+     */
+    @Test
+    public void testMissingGraphsDirFailsScan() {
+        String graphsDir = this.workDir.resolve(MISSING_GRAPHS_DIR).toString();
+        Assert.assertThrows(IllegalArgumentException.class, () -> {
+            ConfigUtil.scanGraphsDir(graphsDir);
+        });
+    }
+
+    @Test
+    public void testDisabledInitStoreExitsBeforeGraphInit() throws Exception {
+        String restConf = this.writeDisabledRestServerConf();
+        // Completes instead of failing on the missing graphs directory, which
+        // proves the gate is applied before any graph or admin initialization
+        InitStore.main(new String[]{restConf});
+    }
+
+    /**
+     * Disabled mode must not reach registerBackends() or registerPlugins(),
+     * since plugin registration propagates every plugin's failures. Backend
+     * options reach OptionSpace only through registerBackends(), so that state
+     * shows whether it ran; OptionSpace is process-wide, hence unchanged 
rather
+     * than empty. The enabled run shares this method so it provably follows 
the
+     * disabled assertions, which registering backends first would make 
vacuous.
+     * It leaves backend providers registered for the rest of the suite's JVM,
+     * and BackendProviderFactory.register() rejects duplicates, so this has to
+     * stay the only enabled run in the class.
+     * <p>
+     * The completion marker is asserted here for the same reason. A disabled
+     * run must not write it: the entrypoint treats it as "already 
initialized",
+     * so recording one for a config mounted with the property set to false
+     * would skip the real initialization for good on a later re-enable. Only
+     * a successful enabled run may write it, which needs a live backend and so
+     * is not covered here.
+     */
+    @Test
+    public void testGateDecidesWhetherRegistrationRuns() throws Exception {
+        boolean backendsRegistered = OptionSpace.containKey(ROCKSDB_OPTION);
+        Path marker = this.workDir.resolve("docker/init_complete");
+        System.setProperty(InitStore.INIT_COMPLETE_MARKER, marker.toString());
+
+        try {
+            InitStore.main(new String[]{this.writeDisabledRestServerConf()});
+
+            Assert.assertEquals(backendsRegistered,
+                                OptionSpace.containKey(ROCKSDB_OPTION));
+            // Server options stay registered, the gate is read from them
+            Assert.assertTrue(OptionSpace.containKey(
+                              ServerOptions.INIT_STORE_ENABLED.name()));
+            Assert.assertFalse("a disabled run initialized nothing",
+                               Files.exists(marker));
+
+            // Absent gate means enabled, so the same config now has to reach
+            // the graph scan and fail on the directory the disabled run never
+            // looked at — a re-enable is not short-circuited by the marker
+            Assert.assertThrows(IllegalArgumentException.class, () -> {
+                InitStore.main(new 
String[]{this.writeEnabledRestServerConf()});
+            });
+            Assert.assertTrue(OptionSpace.containKey(ROCKSDB_OPTION));
+            Assert.assertFalse("a failed run initialized nothing",
+                               Files.exists(marker));
+        } finally {
+            System.clearProperty(InitStore.INIT_COMPLETE_MARKER);
+        }
+    }
+
+    /**
+     * The upgrade scenario: earlier releases wrote the completion marker
+     * from the entrypoint, so one can exist for a configuration that was
+     * never validated — including a later switch to the disabled gate. The
+     * entrypoint half of the regression (skipping init-store entirely on a
+     * present marker) is shell and out of a unit test's reach; what is
+     * pinned here is the ordering invariant the fix relies on instead: the
+     * fail-closed check runs before the marker is consulted, so it fires
+     * with the marker present exactly as {@code
+     * testDisabledInitStoreFailsWhenAdminCannotBeCreated} shows without it.
+     */
+    @Test
+    public void testExistingMarkerDoesNotBypassDisabledPathCheck()
+                                                        throws IOException {
+        System.setProperty(InitStore.INIT_COMPLETE_MARKER,
+                           this.writeExistingMarker().toString());
+        try {
+            String restConf = this.writeDisabledRestServerConf(
+                    ServerOptions.AUTHENTICATOR.name() +
+                    "=" + StandardAuthenticator.class.getName());
+
+            Assert.assertThrows(IllegalStateException.class, () -> {
+                InitStore.main(new String[]{restConf});
+            }, e -> Assert.assertContains("Refusing to skip init-store",
+                                          e.getMessage()));
+        } finally {
+            System.clearProperty(InitStore.INIT_COMPLETE_MARKER);
+        }
+    }
+
+    /**
+     * The enabled path with a present marker is the plain Docker restart: it
+     * must return before the graph scan — completing on a config whose graphs
+     * directory is missing proves that — and before backend registration,
+     * which {@link #testGateDecidesWhetherRegistrationRuns} relies on being
+     * run at most once per JVM.
+     */
+    @Test
+    public void testExistingMarkerSkipsReinitializationWhenEnabled()
+                                                        throws Exception {
+        System.setProperty(InitStore.INIT_COMPLETE_MARKER,
+                           this.writeExistingMarker().toString());
+        try {
+            InitStore.main(new String[]{this.writeEnabledRestServerConf()});
+        } finally {
+            System.clearProperty(InitStore.INIT_COMPLETE_MARKER);
+        }
+    }
+
+    @Test
+    public void testNonRegularMarkerDoesNotSkipInitialization()
+                                                        throws IOException {
+        Path marker = this.workDir.resolve("docker/init_complete");
+        Files.createDirectories(marker);
+        this.assertInvalidMarker(marker);
+
+        Files.delete(marker);
+        Path target = this.workDir.resolve("marker-target");
+        Files.createFile(target);
+        try {
+            Files.createSymbolicLink(marker, target);
+        } catch (UnsupportedOperationException e) {
+            Assume.assumeTrue("Symbolic links are not supported", false);
+        }
+        this.assertInvalidMarker(marker);
+    }
+
+    @Test
+    public void testRecordInitCompleteRejectsNonRegularMarker()
+                                                        throws IOException {
+        Path marker = this.workDir.resolve("docker/init_complete");
+        Files.createDirectories(marker);
+        System.setProperty(InitStore.INIT_COMPLETE_MARKER,
+                           marker.toString());
+        try {
+            Assert.assertThrows(IllegalStateException.class, () -> {
+                Whitebox.invokeStatic(InitStore.class,
+                                       "recordInitComplete");
+            }, e -> Assert.assertContains("regular file", e.getMessage()));
+        } finally {
+            System.clearProperty(InitStore.INIT_COMPLETE_MARKER);
+        }
+    }
+
+    private void assertInvalidMarker(Path marker) throws IOException {
+        System.setProperty(InitStore.INIT_COMPLETE_MARKER,
+                           marker.toString());
+        try {
+            Assert.assertThrows(IllegalStateException.class, () -> {
+                InitStore.main(new 
String[]{this.writeEnabledRestServerConf()});
+            }, e -> Assert.assertContains("regular file", e.getMessage()));
+        } finally {
+            System.clearProperty(InitStore.INIT_COMPLETE_MARKER);
+        }
+    }
+
+    private Path writeExistingMarker() throws IOException {
+        Path marker = this.workDir.resolve("docker/init_complete");
+        Files.createDirectories(marker.getParent());
+        Files.createFile(marker);
+        return marker;
+    }
+
+    /**
+     * The CLI must not report success for a configuration that would start an
+     * auth-enabled server with no admin account, since tarball and init-job
+     * callers only see the exit status.
+     */
+    @Test
+    public void testDisabledInitStoreFailsWhenAdminCannotBeCreated()
+                                                        throws IOException {
+        String restConf = this.writeDisabledRestServerConf(
+                ServerOptions.AUTHENTICATOR.name() +
+                "=" + StandardAuthenticator.class.getName());
+
+        Assert.assertThrows(IllegalStateException.class, () -> {
+            InitStore.main(new String[]{restConf});
+        }, e -> Assert.assertContains("Refusing to skip init-store",
+                                      e.getMessage()));
+    }
+
+    @Test
+    public void testDisabledInitStoreFailsWithNonHstoreAuthGraph()
+                                                        throws IOException {
+        Path graphsDir = this.writeAuthGraphConfig("memory");
+        String restConf = this.writeDisabledRestServerConfForGraphs(
+                graphsDir,
+                ServerOptions.AUTHENTICATOR.name() +
+                "=" + StandardAuthenticator.class.getName(),
+                ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph",
+                ServerOptions.USE_PD.name() + "=true");
+
+        Assert.assertThrows(IllegalStateException.class, () -> {
+            InitStore.main(new String[]{restConf});
+        }, e -> Assert.assertContains("uses backend 'memory', not 'hstore'",
+                                      e.getMessage()));
+    }
+
+    @Test
+    public void testDisabledInitStoreAllowsPdBackedHstoreAuthGraph()
+                                                        throws Exception {
+        Path graphsDir = this.writeAuthGraphConfig("HSTORE");
+        String restConf = this.writeDisabledRestServerConfForGraphs(
+                graphsDir,
+                ServerOptions.AUTHENTICATOR.name() +
+                "=" + StandardAuthenticator.class.getName(),
+                ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph",
+                ServerOptions.USE_PD.name() + "=true",
+                ServerOptions.ADMIN_PA.name() + "=secret");
+
+        InitStore.main(new String[]{restConf});
+    }
+
+    @Test
+    public void testHstoreBackendComparisonIsCaseInsensitive() {
+        Assert.assertTrue(Whitebox.invokeStatic(
+                          InitStore.class, "isHstoreBackend", "hstore"));
+        Assert.assertTrue(Whitebox.invokeStatic(
+                          InitStore.class, "isHstoreBackend", "HSTORE"));
+        Assert.assertFalse(Whitebox.invokeStatic(
+                           InitStore.class, "isHstoreBackend", "memory"));
+    }
+
+    /**
+     * The server creates the admin from auth.admin_pa without prompting, and
+     * Docker PASSWORD never reaches this path, so an absent or empty value
+     * would publish the well-known 'pa' default as a working credential.
+     */
+    @Test
+    public void testDisabledInitStoreRejectsDefaultAdminPassword()
+                                                        throws IOException {
+        Path graphsDir = this.writeAuthGraphConfig("hstore");
+        for (String adminPa : new String[]{null, ""}) {
+            List<String> extra = new ArrayList<>(Arrays.asList(
+                    ServerOptions.AUTHENTICATOR.name() +
+                    "=" + StandardAuthenticator.class.getName(),
+                    ServerOptions.AUTH_GRAPH_STORE.name() + "=hugegraph",
+                    ServerOptions.USE_PD.name() + "=true"));
+            if (adminPa != null) {
+                extra.add(ServerOptions.ADMIN_PA.name() + "=" + adminPa);
+            }
+            String restConf = this.writeDisabledRestServerConfForGraphs(
+                    graphsDir, extra.toArray(new String[0]));
+
+            Assert.assertThrows(IllegalStateException.class, () -> {
+                InitStore.main(new String[]{restConf});
+            }, e -> Assert.assertContains("public default", e.getMessage()));
+        }
+    }
+
+    /**
+     * Remote auth delegates to another service, so there is no local admin to
+     * create and the check above must not fire.
+     */
+    @Test
+    public void testDisabledInitStoreAllowsRemoteAuth() throws Exception {
+        String restConf = this.writeDisabledRestServerConf(
+                ServerOptions.AUTHENTICATOR.name() +
+                "=" + StandardAuthenticator.class.getName(),
+                ServerOptions.AUTH_REMOTE_URL.name() + "=127.0.0.1:8899");
+
+        InitStore.main(new String[]{restConf});
+    }
+
+    /**
+     * auth.authenticator takes any implementation class, and only the built-in
+     * one bootstraps HugeGraph's admin account. A custom authenticator keeps
+     * its identities elsewhere, so the check must not reject it; a subclass of
+     * the built-in one still relies on the same bootstrap.
+     */
+    @Test
+    public void testDisabledInitStoreAllowsCustomAuthenticator()
+                                                        throws Exception {
+        String restConf = this.writeDisabledRestServerConf(
+                ServerOptions.AUTHENTICATOR.name() +
+                "=org.example.auth.LdapAuthenticator");
+
+        InitStore.main(new String[]{restConf});
+
+        String subclassConf = this.writeDisabledRestServerConf(
+                ServerOptions.AUTHENTICATOR.name() + "=" +
+                DerivedAuthenticator.class.getName());
+
+        Assert.assertThrows(IllegalStateException.class, () -> {
+            InitStore.main(new String[]{subclassConf});
+        }, e -> Assert.assertContains("Refusing to skip init-store",
+                                      e.getMessage()));
+    }
+
+    private String writeDisabledRestServerConf(String... extraLines)
+                                               throws IOException {
+        return this.writeDisabledRestServerConfForGraphs(
+                this.workDir.resolve(MISSING_GRAPHS_DIR), extraLines);
+    }
+
+    /**
+     * The same configuration with the gate left out entirely, so it takes the
+     * option's default rather than an explicit value.
+     */
+    private String writeEnabledRestServerConf() throws IOException {
+        Path restConf = this.workDir.resolve(
+                "rest-server-" + this.confSeq++ + ".properties");
+        Files.write(restConf, Arrays.asList(ServerOptions.GRAPHS.name() + "=" +
+                                            
this.workDir.resolve(MISSING_GRAPHS_DIR)),
+                    StandardCharsets.UTF_8);
+        return restConf.toString();
+    }
+
+    private String writeDisabledRestServerConfForGraphs(Path graphsDir,
+                                                        String... extraLines)
+                                                        throws IOException {
+        // A distinct file per call, so two configs written by one test do not
+        // collide inside its temporary directory
+        Path restConf = this.workDir.resolve(
+                "rest-server-" + this.confSeq++ + ".properties");
+        List<String> lines = new ArrayList<>();
+        lines.add(ServerOptions.GRAPHS.name() + "=" + graphsDir);
+        lines.add(ServerOptions.INIT_STORE_ENABLED.name() + "=false");
+        lines.addAll(Arrays.asList(extraLines));
+        Files.write(restConf, lines, StandardCharsets.UTF_8);
+        return restConf.toString();
+    }
+
+    private Path writeAuthGraphConfig(String backend) throws IOException {
+        Path graphsDir = this.workDir.resolve("graphs-" + this.confSeq++);
+        Files.createDirectories(graphsDir);
+        Files.write(graphsDir.resolve("hugegraph.properties"),
+                    Arrays.asList(CoreOptions.BACKEND.name() + "=" + backend),
+                    StandardCharsets.UTF_8);
+        return graphsDir;
+    }
+
+    /**
+     * Stands in for a deployment that subclasses the built-in authenticator,
+     * which still depends on the admin account it creates.
+     */
+    public static class DerivedAuthenticator extends StandardAuthenticator {
+    }
+}
diff --git 
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerAdminInitTest.java
 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerAdminInitTest.java
new file mode 100644
index 000000000..60e59666f
--- /dev/null
+++ 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/GraphManagerAdminInitTest.java
@@ -0,0 +1,166 @@
+/*
+ * 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.hugegraph.unit.core;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.apache.hugegraph.HugeException;
+import org.apache.hugegraph.auth.HugeAuthenticator;
+import org.apache.hugegraph.auth.HugeUser;
+import org.apache.hugegraph.auth.StandardAuthenticator;
+import org.apache.hugegraph.config.HugeConfig;
+import org.apache.hugegraph.core.GraphManager;
+import org.apache.hugegraph.event.EventHub;
+import org.apache.hugegraph.meta.MetaDriver;
+import org.apache.hugegraph.meta.MetaManager;
+import org.apache.hugegraph.meta.managers.AuthMetaManager;
+import org.apache.hugegraph.meta.managers.SpaceMetaManager;
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * {@link GraphManager#initAdminUserIfNeeded} is the only bootstrap the
+ * built-in admin gets when init-store is disabled, and init-store's
+ * fail-closed check assumes it works. Only the already-exists case is benign;
+ * every other failure must abort startup rather than leave the server up
+ * without a usable administrator.
+ */
+public class GraphManagerAdminInitTest {
+
+    private static final String CLUSTER = "admin-init-test";
+
+    private Map<String, String> store;
+    private MetaDriver driver;
+    private Object originalAuthManager;
+    private Object originalSpaceManager;
+    private GraphManager manager;
+
+    @Before
+    public void setup() throws Exception {
+        this.store = new HashMap<>();
+        this.driver = Mockito.mock(MetaDriver.class);
+        Mockito.when(this.driver.get(Mockito.anyString())).thenAnswer(
+                i -> this.store.get(i.<String>getArgument(0)));
+        Mockito.doAnswer(i -> this.store.put(i.getArgument(0),
+                                             i.getArgument(1)))
+               .when(this.driver)
+               .put(Mockito.anyString(), Mockito.anyString());
+
+        this.originalAuthManager = swapMetaManagerField(
+                "authMetaManager", new AuthMetaManager(this.driver, CLUSTER));
+        this.originalSpaceManager = swapMetaManagerField(
+                "spaceMetaManager", new SpaceMetaManager(this.driver, 
CLUSTER));
+        this.manager = new GraphManager(
+                new HugeConfig(new PropertiesConfiguration()),
+                new EventHub("admin-init-test"));
+    }
+
+    @After
+    public void teardown() throws Exception {
+        try {
+            if (this.manager != null) {
+                this.manager.close();
+            }
+        } finally {
+            swapMetaManagerField("authMetaManager", this.originalAuthManager);
+            swapMetaManagerField("spaceMetaManager",
+                                 this.originalSpaceManager);
+        }
+    }
+
+    @Test
+    public void testCreatesAdminOnFreshMetadata() throws Exception {
+        this.manager.initAdminUserIfNeeded("s3cret");
+
+        HugeUser admin = MetaManager.instance().findUser("admin");
+        Assert.assertNotNull("the admin must be created", admin);
+    }
+
+    /**
+     * Every restart after the first sees the admin already recorded and
+     * surfaces the already-exists signal, which must neither fail startup
+     * nor rotate the existing password. (True concurrency is weaker than
+     * this: createUser is get-then-put without compare-and-set, so a tight
+     * race can overwrite rather than throw — benign only because every
+     * server writes the admin derived from the same configured password.)
+     */
+    @Test
+    public void testExistingAdminIsKeptWithoutFailing() throws Exception {
+        this.manager.initAdminUserIfNeeded("first");
+        HugeUser created = MetaManager.instance().findUser("admin");
+
+        this.manager.initAdminUserIfNeeded("second");
+
+        HugeUser kept = MetaManager.instance().findUser("admin");
+        Assert.assertNotNull(kept);
+        Assert.assertEquals("an existing admin's password must not rotate",
+                            created.password(), kept.password());
+    }
+
+    /**
+     * A PD write, permission or validation failure used to be logged and
+     * swallowed, so the server started with no usable administrator. It has
+     * to propagate instead: the failure is not the already-exists case,
+     * proven by the admin still being absent.
+     */
+    @Test
+    public void testNonDuplicateCreationFailurePropagates() {
+        RuntimeException refused = new RuntimeException("pd write refused");
+        Mockito.doThrow(refused).when(this.driver)
+               .put(Mockito.anyString(), Mockito.anyString());
+
+        Assert.assertThrows(HugeException.class, () -> {
+            this.manager.initAdminUserIfNeeded("s3cret");
+        }, e -> Assert.assertEquals(refused, e.getCause()));
+    }
+
+    @Test
+    public void testAdminBootstrapOnlyUsesLocalStandardAuthenticator() {
+        Class<?>[] types = {HugeAuthenticator.class, String.class};
+
+        Assert.assertFalse(Whitebox.invokeStatic(
+                GraphManager.class, types, "shouldBootstrapAdmin",
+                (Object) null, ""));
+        Assert.assertTrue(Whitebox.invokeStatic(
+                GraphManager.class, types, "shouldBootstrapAdmin",
+                new StandardAuthenticator(), ""));
+        Assert.assertFalse(Whitebox.invokeStatic(
+                GraphManager.class, types, "shouldBootstrapAdmin",
+                new StandardAuthenticator(), "pd:8520"));
+        Assert.assertFalse(Whitebox.invokeStatic(
+                GraphManager.class, types, "shouldBootstrapAdmin",
+                Mockito.mock(HugeAuthenticator.class), ""));
+    }
+
+    private static Object swapMetaManagerField(String field,
+                                               Object replacement)
+                                               throws Exception {
+        Field f = MetaManager.class.getDeclaredField(field);
+        f.setAccessible(true);
+        Object previous = f.get(MetaManager.instance());
+        f.set(MetaManager.instance(), replacement);
+        return previous;
+    }
+}

Reply via email to