diff --git a/.asf.yaml b/.asf.yaml
index 9aa07d545a..a2e0b4aa67 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -33,16 +33,10 @@ github:
protected_branches:
master:
required_status_checks:
- # Keep workflows providing these checks unfiltered; path skips can leave required checks pending.
# strict means "Require PR to be up-to-date before merging". (enable when branch unstable)
strict: false
- # contexts are the names of checks that must pass (now only enable the basic check)
- contexts:
- - Analyze (java)
- - CodeQL
- - check-license
- - build-server (memory, 11)
- - build-commons (11)
+ # CI remains visible but advisory while shared runner queues are constrained.
+ contexts: []
required_pull_request_reviews:
dismiss_stale_reviews: true
require_code_owner_reviews: false
diff --git a/.editorconfig b/.editorconfig
index c64c7bae2b..7e5f93a2f5 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -36,6 +36,7 @@ continuation_indent_size = 8
[*.md]
max_line_length = off
+ij_wrap_on_typing = false
[*.java]
ij_java_doc_add_p_tag_on_empty_lines = true
diff --git a/.gitattributes b/.gitattributes
index 216223172b..bfadaca912 100755
--- a/.gitattributes
+++ b/.gitattributes
@@ -15,5 +15,8 @@ install-dist/scripts/ export-ignore
hugegraph-commons/hugegraph-dist/ export-ignore
docker/ export-ignore
+# Documentation images are not needed in source releases.
+/docs/images/ export-ignore
+
# Keep the generated golden corpus byte-identical across checkouts
hugegraph-commons/hugegraph-common/src/test/resources/longencoding-golden.txt text eol=lf
diff --git a/.github/codecov.yml b/.github/codecov.yml
new file mode 100644
index 0000000000..b359fe93cc
--- /dev/null
+++ b/.github/codecov.yml
@@ -0,0 +1,22 @@
+# 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.
+
+# Keep coverage from unexecuted test groups in affected-input CI reports.
+flag_management:
+ default_rules:
+ carryforward: true
+
+comment:
+ show_carryforward_flags: true
diff --git a/.github/images/architecture.png b/.github/images/architecture.png
new file mode 100644
index 0000000000..d14f6838a8
Binary files /dev/null and b/.github/images/architecture.png differ
diff --git a/.github/outdated/APACHE_PROPOSAL.md b/.github/outdated/APACHE_PROPOSAL.md
index bc3bb84044..b26681d2c1 100644
--- a/.github/outdated/APACHE_PROPOSAL.md
+++ b/.github/outdated/APACHE_PROPOSAL.md
@@ -34,7 +34,7 @@ Although most of the main development of HugeGraph has been completed, there are
#### Meritocracy
-HugeGraph was incubated at Baidu in 2016 and open-sourced on [GitHub](https://github.com/hugegraph/hugegraph) in 2018. The project (including sub-projects) now has 30+ contributors from many companies. Some of these contributors become committers, and the project has hundreds of known users around the world. We will follow Apache's Meritocracy way to re-organize the community roles. We have set up the PPMC Team and Committer Team. Of course, contributions are welcomed and highly valued. New contributors are guided and reviewed by existing PMC members. When an active contributor has submitted enough good patches, PMC will start a vote to promote him/her to become a member of the Committer Team or PMC Team.
+HugeGraph was incubated at Baidu in 2016 and open-sourced on [GitHub](https://github.com/apache/hugegraph) in 2018. The project (including sub-projects) now has 30+ contributors from many companies. Some of these contributors become committers, and the project has hundreds of known users around the world. We will follow Apache's Meritocracy way to re-organize the community roles. We have set up the PPMC Team and Committer Team. Of course, contributions are welcomed and highly valued. New contributors are guided and reviewed by existing PMC members. When an active contributor has submitted enough good patches, PMC will start a vote to promote him/her to become a member of the Committer Team or PMC Team.
#### Community
@@ -52,7 +52,7 @@ HugeGraph implements the API of [Apache TinkerPop](https://tinkerpop.apache.org/
#### Project Name
-We have checked and believe the name is [suitable](https://github.com/hugegraph/hugegraph/issues/1646) and the project has legal permission to continue using its current name. There are no other projects found using this name through Google Search.
+We have checked and believe the name is [suitable](https://github.com/apache/hugegraph/issues/1646) and the project has legal permission to continue using its current name. There are no other projects found using this name through Google Search.
#### Relationship with Titan/Janus Graph
@@ -94,10 +94,10 @@ HugeGraph documentation is provided on https://hugegraph.github.io/hugegraph-doc
This project consists of 2 core sub-projects and 2 other sub-projects, all of which are hosted by [GitHub hugegraph organization](https://github.com/orgs/hugegraph/repositories) since 2018. The codes are already under Apache License Version 2.0. The git address of sub-project repositories are as follows:
-1. The graph database repository `hugegraph`, core sub-project, including graph server, graph engine, and graph storage: https://github.com/hugegraph/hugegraph
-2. The graph computing repository `hugegraph-computer`, core sub-project, including graph computing and graph algorithms: https://github.com/hugegraph/hugegraph-computer
-3. The common functions repository `hugegraph-commons`: https://github.com/hugegraph/hugegraph-commons
-4. The ecosystem repository `hugegraph-toolchain`, including `hugegraph-client`, `hugegraph-loader`, `hugegraph-tools`, `hugegraph-hubble`, `hugegraph-test`, `hugegraph-doc`: https://github.com/hugegraph/hugegraph-toolchain
+1. The graph database repository `hugegraph`, core sub-project, including graph server, graph engine, and graph storage: https://github.com/apache/hugegraph
+2. The graph computing repository `hugegraph-computer`, core sub-project, including graph computing and graph algorithms: https://github.com/apache/hugegraph-computer
+3. The common functions repository `hugegraph-commons`: https://github.com/apache/hugegraph/tree/master/hugegraph-commons
+4. The ecosystem repository `hugegraph-toolchain`, including `hugegraph-client`, `hugegraph-loader`, `hugegraph-tools`, `hugegraph-hubble`, `hugegraph-test`, `hugegraph-doc`: https://github.com/apache/hugegraph-toolchain
### Source and Intellectual Property Submission Plan
@@ -107,7 +107,7 @@ The code is currently under Apache License Version 2.0, and it was verified ther
As all dependencies are managed by Apache Maven, none of the external libraries need to be packaged in a source distribution. All dependencies have Apache compatible licenses except for 4 dependencies: MySQL Connector (GPL), word (GPL), JBoss Logging 3 (LGPL), and jnr-posix (LGPL+GPL), we will remove these dependencies in the future.
-HugeGraph has the following external [dependencies](https://github.com/hugegraph/hugegraph/issues/1632):
+HugeGraph has the following external [dependencies](https://github.com/apache/hugegraph/issues/1632):
- Apache License
@@ -263,7 +263,7 @@ None
- hugegraph-dev: [dev@hugegraph.incubator.apache.org](mailto:dev@hugegraph.incubator.apache.org) for development and users discussions.
- hugegraph-private: [private@hugegraph.incubator.apache.org](mailto:private@hugegraph.incubator.apache.org) for PPMC discussions.
-- hugegraph-commits: [commits@hugegraph.incubator.apache.org](mailto:commits@hugegraph.incubator.apache.org) for commits / [pull requests](https://github.com/hugegraph/hugegraph/pulls) and other notifications like code review comments.
+- hugegraph-commits: [commits@hugegraph.incubator.apache.org](mailto:commits@hugegraph.incubator.apache.org) for commits / [pull requests](https://github.com/apache/hugegraph/pulls) and other notifications like code review comments.
#### Subversion Directory
@@ -271,18 +271,18 @@ None
#### Git Repositories
-1. `hugegraph`: https://github.com/hugegraph/hugegraph.git
-2. `hugegraph-computer`: https://github.com/hugegraph/hugegraph-computer.git
-3. `hugegraph-commons`: https://github.com/hugegraph/hugegraph-commons.git
-4. `hugegraph-toolchain`: https://github.com/hugegraph/hugegraph-toolchain.git
+1. `hugegraph`: https://github.com/apache/hugegraph.git
+2. `hugegraph-computer`: https://github.com/apache/hugegraph-computer.git
+3. `hugegraph-commons`: https://github.com/apache/hugegraph.git
+4. `hugegraph-toolchain`: https://github.com/apache/hugegraph-toolchain.git
#### Issue Tracking
-The community would like to continue using [GitHub Issues](https://github.com/hugegraph/hugegraph/issues) (but will move to github.com/apache/).
+The community would like to continue using [GitHub Issues](https://github.com/apache/hugegraph/issues) (but will move to github.com/apache/).
#### Other Resources
-- The community has already chosen [GitHub actions](https://github.com/hugegraph/hugegraph/actions) as continuous integration tools.
+- The community has already chosen [GitHub actions](https://github.com/apache/hugegraph/actions) as continuous integration tools.
- The community has already used [codecov](https://github.com/marketplace/codecov) to check code coverage.
- The community has already used [mvn repository](https://mvnrepository.com/search?q=hugegraph) as binary package release platform.
diff --git a/.github/outdated/cla.yml b/.github/outdated/cla.yml
index a182b9515b..d5779dcb37 100644
--- a/.github/outdated/cla.yml
+++ b/.github/outdated/cla.yml
@@ -18,8 +18,8 @@ jobs:
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
path-to-signatures: '.github/cla-signatures/cla.json'
- path-to-document: 'https://github.com/hugegraph/hugegraph-doc/blob/master/CLA.md' # e.g. a CLA or a DCO document
- custom-allsigned-prcomment: '**Good**! All Contributors have signed the [CLA](https://github.com/hugegraph/hugegraph-doc/blob/master/CLA.md).'
+ path-to-document: 'https://www.apache.org/licenses/contributor-agreements.html' # e.g. a CLA or a DCO document
+ custom-allsigned-prcomment: '**Good**! All Contributors have signed the [CLA](https://www.apache.org/licenses/contributor-agreements.html).'
# branch should not be protected
branch: 'cla-assist'
allowlist: bot*
diff --git a/.github/scripts/check-docker-images.sh b/.github/scripts/check-docker-images.sh
new file mode 100644
index 0000000000..41c8911246
--- /dev/null
+++ b/.github/scripts/check-docker-images.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env 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
+: "${IMAGE_TAG:?set the unique CI image tag}"
+for pair in \
+ hugegraph-pd/Dockerfile=hugegraph/pd \
+ hugegraph-store/Dockerfile=hugegraph/store \
+ hugegraph-server/Dockerfile=hugegraph/hugegraph \
+ hugegraph-server/Dockerfile-hstore=hugegraph/server; do
+ (
+ dockerfile="${pair%%=*}"
+ IMAGE_ID=$(docker image inspect --format '{{.Id}}' "${pair#*=}:${IMAGE_TAG}")
+ echo "Checking ${pair#*=}:${IMAGE_TAG} ($IMAGE_ID)"
+ HC=$(docker image inspect --format '{{json .Config.Healthcheck}}' "$IMAGE_ID")
+ [[ "$HC" != null ]] || { echo "Missing HEALTHCHECK: $dockerfile"; exit 1; }
+
+ # Helm reuses these images for tool containers instead of pulling curlimages/curl.
+ # Check the same non-root/read-only tool environment as the Server test hook.
+ if [[ "$dockerfile" == hugegraph-store/* || "$dockerfile" == hugegraph-server/* ]]; then
+ HELM_TOOLS=(curl tr date sleep)
+ if [[ "$dockerfile" == hugegraph-server/* ]]; then
+ HELM_TOOLS+=(getent base64 awk grep sort tail)
+ fi
+ docker run --rm --pull never --read-only --user 100:101 --entrypoint sh "$IMAGE_ID" -c '
+ for tool in "$@"; do
+ command -v "$tool" >/dev/null || {
+ echo "Missing Helm tool: $tool" >&2
+ exit 1
+ }
+ case "$tool" in
+ getent) getent ahosts localhost >/dev/null ;;
+ base64) test "$(printf Helm | base64 | tr -d "\n")" = SGVsbQ== ;;
+ esac
+ done
+ curl --version >/dev/null
+ ' helm-tools "${HELM_TOOLS[@]}"
+ fi
+
+ JAVA_VERSION=$(docker run --rm --pull never --entrypoint java "$IMAGE_ID" -version 2>&1)
+ echo "$JAVA_VERSION"
+ grep -Eq 'version "17\.' <<< "$JAVA_VERSION" || {
+ echo "ERROR: expected a Java 17 runtime in $dockerfile"
+ exit 1
+ }
+
+ if [[ "$dockerfile" == hugegraph-server/* ]]; then
+ STATE=$(docker run --rm --pull never "$IMAGE_ID" bash -c \
+ 'source /hugegraph-server/bin/util.sh && port_listen_state 8080')
+ echo "port_listen_state 8080 -> $STATE"
+ # Require a usable answer from the socket-table tool in the image.
+ [[ "$STATE" == "free" || "$STATE" == "busy" ]] || {
+ echo "ERROR: no usable socket-table tool (ss/netstat) in $dockerfile"
+ exit 1
+ }
+ fi
+
+ if [[ "$dockerfile" == hugegraph-server/* ]]; then
+ CHECK_DIR=$(mktemp -d)
+ trap 'rm -rf "$CHECK_DIR"' EXIT
+ docker run --rm --pull never --entrypoint bash \
+ -v "$CHECK_DIR:/check" "$IMAGE_ID" -c \
+ 'cp /hugegraph-server/lib/hugegraph-api-*.jar \
+ /hugegraph-server/lib/hugegraph-common-*.jar /check/'
+
+ API_JAR=$(find "$CHECK_DIR" -name 'hugegraph-api-*.jar' -print -quit)
+ COMMON_JAR=$(find "$CHECK_DIR" -name 'hugegraph-common-*.jar' -print -quit)
+ EXPECTED_MANIFEST=$(sed -n \
+ 's|.*\([^<]*\).*|\1|p' \
+ hugegraph-server/hugegraph-api/pom.xml)
+ ACTUAL_MANIFEST=$(unzip -p "$API_JAR" META-INF/MANIFEST.MF |
+ sed -n 's/^Implementation-Version: *//p' | tr -d '\r')
+ EXPECTED_PROPERTY=$(sed -n 's/^ApiVersion=//p' \
+ hugegraph-commons/hugegraph-common/src/main/resources/version.properties)
+ ACTUAL_PROPERTY=$(unzip -p "$COMMON_JAR" version.properties |
+ sed -n 's/^ApiVersion=//p' | tr -d '\r')
+
+ [[ "$ACTUAL_MANIFEST" == "$EXPECTED_MANIFEST" ]] || {
+ echo "ERROR: API manifest is $ACTUAL_MANIFEST; expected $EXPECTED_MANIFEST"
+ exit 1
+ }
+ [[ "$ACTUAL_PROPERTY" == "$EXPECTED_PROPERTY" ]] || {
+ echo "ERROR: API property is $ACTUAL_PROPERTY; expected $EXPECTED_PROPERTY"
+ exit 1
+ }
+ fi
+ )
+done
diff --git a/.github/scripts/ci-policy.py b/.github/scripts/ci-policy.py
new file mode 100644
index 0000000000..6241fffda4
--- /dev/null
+++ b/.github/scripts/ci-policy.py
@@ -0,0 +1,453 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Select affected CI checks from the current event and cumulative PR diff."""
+
+import argparse
+import json
+import os
+from pathlib import Path
+import subprocess
+
+MAINLINE = {"server", "commons", "struct", "pd", "store", "hstore", "cluster", "docker", "helm",
+ "dependency_license", "macos", "security", "distributed_smoke"}
+MODULES = {
+ "server": sorted(MAINLINE | {"macos_full", "hbase", "riscv64"}),
+ "toolchain": ["client", "loader", "tools", "spark", "hubble", "go"],
+}
+PREFIXES = {
+ "server": {"hugegraph-server/": "server", "hugegraph-commons/": "commons",
+ "hugegraph-struct/": "struct", "hugegraph-pd/": "pd", "hugegraph-store/": "store",
+ "hugegraph-cluster-test/": "cluster", "docker/": "docker", "helm/": "helm"},
+ "toolchain": {"hugegraph-client/": "client", "hugegraph-client-go/": "go",
+ "hugegraph-loader/": "loader", "hugegraph-tools/": "tools",
+ "hugegraph-spark-connector/": "spark", "hugegraph-hubble/": "hubble"},
+}
+WORKFLOWS = {
+ "server": {"server-ci.yml": MAINLINE, "commons-ci.yml": {"commons"},
+ "pd-store-ci.yml": {"struct", "pd", "store", "hstore"}, "cluster-test-ci.yml": {"cluster"},
+ "docker-build-ci.yml": {"docker"}, "helm-chart-ci.yml": {"helm"},
+ "codeql-analysis.yml": {"security"}, "server-memory-ci.yml": {"server"},
+ "server-tests.yml": {"server", "hbase"},
+ "server-compatibility-ci.yml": {"macos", "macos_full", "hbase", "riscv64"},
+ "riscv64-ci.yml": {"riscv64"}, "check-dependencies.yml": {"dependency_license"}},
+ "toolchain": {"client-ci.yml": {"client"}, "client-go-ci.yml": {"go"},
+ "loader-ci.yml": {"loader"}, "tools-ci.yml": {"tools"},
+ "spark-connector-ci.yml": {"spark"}, "hubble-ci.yml": {"hubble"},
+ "codeql-analysis.yml": set()},
+}
+# Shared runtime consumers; ordinary Server edits do not fan out to PD/Store.
+DEPENDENTS = {
+ "server": {"commons": {"server", "pd", "store", "hstore", "cluster"},
+ "struct": {"server", "pd", "store", "hstore", "cluster"},
+ "pd": {"store", "hstore", "cluster"}, "store": {"hstore", "cluster"}, "hstore": {"cluster"}},
+ "toolchain": {"client": {"loader", "tools", "spark", "hubble"}, "loader": {"hubble"}},
+}
+MAINTENANCE_INPUTS = {
+ ".asf.yaml": set(), ".editorconfig": set(), ".licenserc.yaml": set(),
+ ".github/PULL_REQUEST_TEMPLATE.md": set(), ".github/dependabot.yml": set(),
+ ".github/workflows/.java-version": MAINLINE | {"macos_full", "hbase", "riscv64"},
+ ".github/scripts/check-docker-images.sh": {"docker"},
+ ".github/scripts/docker-deployment.py": {"docker"},
+ ".github/scripts/test_docker_deployment.py": {"docker"},
+}
+PRESETS = {
+ "mainline": MAINLINE,
+ "distributed": {"struct", "pd", "store", "hstore", "cluster", "distributed_smoke"},
+ "macos-arm": {"macos", "macos_full"}, "hbase": {"hbase"},
+ "riscv64": {"riscv64"}, "codeql": {"security"}, "full": set(MODULES["server"]),
+}
+SERVER_CORE = "hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/"
+SERVER_API = "hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/"
+SECURITY_INPUTS = {
+ SERVER_CORE + "backend/cache/CachedSchemaTransaction.java",
+ SERVER_CORE + "backend/cache/CachedSchemaTransactionV2.java",
+}
+DISTRIBUTED_SERVER_INPUTS = (
+ "hugegraph-server/hugegraph-hstore/", SERVER_CORE + "backend/", SERVER_CORE + "meta/",
+ SERVER_CORE + "space/", SERVER_CORE + "task/", SERVER_CORE + "masterelection/", SERVER_CORE + "HugeGraph",
+ SERVER_CORE + "StandardHugeGraph", SERVER_API + "core/", SERVER_API + "rpc/",
+ SERVER_API + "api/raft/", SERVER_API + "api/space/",
+ "hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/store/",
+ "hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/meta/",
+)
+# Test/resources/templates can contain executable examples and runtime fixtures.
+RUNTIME_PARTS = {"src", "resources", "fixtures", "templates", "static", "bin", "conf", "configs"}
+# These build files declare native JNI dependencies/classifiers or the RISC-V protobuf profile.
+PLATFORM_POMS = {"pom.xml", "hugegraph-server/pom.xml", "hugegraph-server/hugegraph-core/pom.xml",
+ "hugegraph-server/hugegraph-rocksdb/pom.xml", "hugegraph-server/hugegraph-dist/pom.xml",
+ "hugegraph-pd/hg-pd-grpc/pom.xml"}
+RISCV_PROTO_ROOTS = ("hugegraph-server/hugegraph-core/", "hugegraph-pd/hg-pd-grpc/")
+TRAVIS = "hugegraph-server/hugegraph-dist/src/assembly/travis/"
+# Contract tests follow their executing suites, without runtime consumer fan-out.
+CONTRACT_SUITES = {
+ TRAVIS + "test-check-java-runtime-logs.sh": {"struct"},
+ TRAVIS + "check-java-runtime-logs.sh": {"server", "commons", "struct", "pd", "store", "hstore", "cluster",
+ "macos", "macos_full", "hbase", "riscv64"},
+ TRAVIS + "test-stop-hugegraph-store.sh": {"struct"},
+ TRAVIS + "test-check-jacoco-report.sh": {"struct"},
+ TRAVIS + "test-codecov-upload-config.sh": {"struct"},
+ TRAVIS + "test-riscv64-java-runtime.sh": {"server", "riscv64"},
+}
+
+
+def git(*args):
+ return subprocess.check_output(["git", *args], text=True).strip()
+
+
+def api(path):
+ result = subprocess.run(["gh", "api", "--method", "GET", path], check=True, capture_output=True, timeout=30)
+ return json.loads(result.stdout)
+
+
+def documentation(path, mode="100644"):
+ """Only plain prose/static assets outside runtime and fixture directories."""
+ if mode != "100644":
+ return False
+ p = Path(path)
+ if RUNTIME_PARTS.intersection(p.parts[:-1]):
+ return False
+ if p.suffix.lower() in {".md", ".rst"}:
+ return True
+ if len(p.parts) == 1 and (p.suffix.lower() == ".txt" or p.name in {"LICENSE", "NOTICE"}):
+ return True
+ return "docs" in p.parts[:-1] and p.suffix.lower() in {".md", ".rst", ".txt", ".png", ".jpg", ".jpeg", ".svg"}
+
+
+def unsafe_documentation(ref, paths):
+ entries = subprocess.check_output(["git", "ls-tree", "-rz", "--full-tree", ref]).split(b"\0")
+ for entry in entries:
+ if not entry:
+ continue
+ metadata, name = entry.split(b"\t", 1)
+ path = name.decode("utf-8", "surrogateescape")
+ if path in paths and documentation(path) and metadata.split(b" ", 1)[0] != b"100644":
+ return True
+ return False
+
+
+def select(project, paths):
+ selected = set()
+ contracts = set()
+ for path in paths:
+ if documentation(path):
+ continue
+ p = Path(path)
+ if project == "server":
+ if path in CONTRACT_SUITES:
+ contracts.update(CONTRACT_SUITES[path])
+ continue
+ if path in MAINTENANCE_INPUTS:
+ selected.update(MAINTENANCE_INPUTS[path])
+ continue
+ if p.name == "pom.xml":
+ selected.update({"dependency_license", "security"})
+ if path in PLATFORM_POMS:
+ selected.update({"macos", "macos_full", "riscv64"})
+ if path.startswith("install-dist/"):
+ selected.add("dependency_license")
+ continue
+ if (path in SECURITY_INPUTS
+ or {"auth", "security", "permission", "permissions"}.intersection(p.parts)
+ or p.stem.startswith(("Authentication", "Authorization"))
+ or p.stem.endswith(("Authentication", "Authenticator", "Permission", "WithAuth"))
+ or p.name in {"AuthOptions.java", "OkHttpBasicAuthInterceptor.java", "java-security.properties",
+ "test-java-security-properties.sh", "enable-auth.sh"}):
+ selected.add("security")
+ if path.startswith(".github/workflows/") and p.name in WORKFLOWS[project]:
+ selected.update(WORKFLOWS[project][p.name])
+ continue
+ if path == TRAVIS + "install-backend.sh":
+ selected.update({"hbase", "hstore"})
+ if path.startswith("docker/hbase/"):
+ selected.update({"docker", "hbase"})
+ continue
+ if path.startswith("hugegraph-server/hugegraph-hbase/") or "hbase" in p.name.lower():
+ selected.add("hbase")
+ continue
+ if "riscv" in path.lower():
+ selected.add("riscv64")
+ continue
+ if path.startswith(DISTRIBUTED_SERVER_INPUTS) or path.endswith(".proto"):
+ selected.update({"hstore", "cluster", "distributed_smoke"})
+ if path.endswith(".proto") and path.startswith(RISCV_PROTO_ROOTS):
+ selected.add("riscv64")
+ if path.startswith("hugegraph-server/hugegraph-dist/docker/"):
+ selected.add("docker")
+ continue
+ if path.startswith("hugegraph-server/hugegraph-dist/"):
+ selected.update({"server", "docker", "macos", "macos_full"})
+ if (path.startswith("hugegraph-server/hugegraph-dist/src/assembly/static/bin/")
+ or "native-runtime" in p.name or "rocksdb-jni" in p.name
+ or p.name == "check-rocksdb-only-dist.sh"):
+ selected.add("riscv64")
+ if any(word in path for word in ("pd", "store", "distributed")):
+ selected.update({"hstore", "cluster", "distributed_smoke"})
+ continue
+ if path.startswith("hugegraph-server/hugegraph-rocksdb/"):
+ selected.update({"server", "macos", "macos_full"})
+ continue
+ if path in {"hugegraph-server/pom.xml", "hugegraph-server/hugegraph-api/pom.xml"}:
+ selected.add("docker")
+ if path.startswith(("hugegraph-pd/hg-pd-dist/", "hugegraph-store/hg-store-dist/")):
+ selected.add("docker")
+ if path == "hugegraph-commons/hugegraph-common/src/main/resources/version.properties":
+ selected.add("docker")
+ elif path.endswith(".proto"):
+ selected.update(MODULES[project])
+ continue
+ elif path.startswith(".github/workflows/") and p.name in WORKFLOWS[project]:
+ selected.update(WORKFLOWS[project][p.name])
+ continue
+ elif path.startswith("hugegraph-client/assembly/travis/"):
+ selected.update({"client", "go"})
+ continue
+ module = next((value for prefix, value in PREFIXES[project].items() if path.startswith(prefix)), None)
+ if module is None:
+ selected.update(MAINLINE if project == "server" else MODULES[project])
+ else:
+ selected.add(module)
+ changed = True
+ while changed:
+ before = set(selected)
+ for module in before:
+ selected.update(DEPENDENTS[project].get(module, set()))
+ changed = before != selected
+ if project == "server":
+ if "server" in selected:
+ selected.add("macos")
+ if selected.intersection({"pd", "store", "hstore", "struct", "cluster"}):
+ selected.add("distributed_smoke")
+ selected.update(contracts)
+ return selected
+
+
+def event_selection(project, paths, event, event_name):
+ """Presets and post-merge smoke share the same affected-input classifier."""
+ if project != "server":
+ return select(project, paths)
+ if event_name == "schedule":
+ return set(PRESETS["full"])
+ if event_name == "workflow_dispatch":
+ preset = event.get("inputs", {}).get("test-set", "mainline")
+ if preset not in PRESETS:
+ raise ValueError("unknown test-set: " + str(preset))
+ return set(PRESETS[preset])
+ if event_name == "push" and event.get("ref") == "refs/heads/master":
+ # Test contracts do not change deployed runtime inputs.
+ selected = select(project, [path for path in paths if path not in CONTRACT_SUITES])
+ smoke = selected.intersection({"docker", "helm", "dependency_license", "macos", "distributed_smoke"})
+ if selected.intersection({"server", "commons", "struct", "pd", "store", "hstore", "cluster"}):
+ smoke.add("distributed_smoke")
+ return smoke
+ return select(project, paths)
+
+
+def suites(project, selected):
+ if project == "toolchain":
+ return sorted(selected)
+ result = selected.intersection({"commons", "cluster", "docker", "helm", "dependency_license", "macos",
+ "hbase", "riscv64", "security", "distributed_smoke"})
+ if "server" in selected:
+ result.update({"server_memory", "server_rocksdb"})
+ if selected.intersection({"pd", "store", "hstore", "struct"}):
+ result.add("pd_store")
+ return sorted(result)
+
+
+class StaleInputError(RuntimeError):
+ """The checkout no longer represents the current PR inputs."""
+
+
+def require_current_pr(plan, fetch):
+ if not plan["pr"]:
+ return
+ live = fetch(f"repos/{plan['repository']}/pulls/{plan['pr']}")
+ if (live.get("state") != "open" or live["head"]["sha"] != plan["head"]
+ or live["head"]["repo"]["full_name"] != plan["source"]
+ or live["head"]["ref"] != plan["branch"]
+ or live["base"]["repo"]["full_name"] != plan["repository"]):
+ raise StaleInputError("PR inputs changed; refresh the branch and start a new PR run")
+
+
+def create_plan(project, event, repository, fetch=api, event_name=None):
+ event_name = event_name or os.environ.get("GITHUB_EVENT_NAME") or (
+ "pull_request" if "pull_request" in event else "push")
+ paths = []
+ plan = {"schema": 1, "project": project, "repository": repository, "pr": 0,
+ "source": repository, "branch": "", "base": "", "head": git("rev-parse", "HEAD"),
+ "reason": "affected inputs", "testedMergeSHA": git("rev-parse", "HEAD"), "event": event_name}
+ try:
+ pr = event.get("pull_request")
+ if "pull_request" in event:
+ if not isinstance(pr, dict) or type(pr.get("number")) is not int or pr["number"] <= 0:
+ raise StaleInputError("PR event lacks a valid number")
+ plan["pr"] = pr["number"]
+ try:
+ plan.update(source=pr["head"]["repo"]["full_name"], base=pr["base"]["sha"],
+ head=pr["head"]["sha"], branch=pr["head"]["ref"])
+ except (KeyError, TypeError) as error:
+ raise StaleInputError("PR event lacks required input identity") from error
+ if not all(isinstance(plan[key], str) and plan[key] for key in ("source", "base", "head", "branch")):
+ raise StaleInputError("PR event has an empty input identity")
+ parents = git("show", "-s", "--format=%P", plan["testedMergeSHA"]).split()
+ if (plan["testedMergeSHA"] != os.environ.get("GITHUB_SHA")
+ or len(parents) != 2 or parents[1] != plan["head"]):
+ raise StaleInputError("checkout is not the event PR merge; start a new PR run")
+ plan["base"] = parents[0]
+ require_current_pr(plan, fetch)
+ ancestor = git("merge-base", plan["base"], plan["head"])
+ paths = git("diff", "--no-renames", "--name-only", ancestor, plan["head"]).splitlines()
+ elif event_name == "push":
+ plan["base"] = event.get("before", "")
+ plan["branch"] = event.get("ref", "").removeprefix("refs/heads/")
+ paths = git("diff", "--no-renames", "--name-only", plan["base"], plan["head"]).splitlines()
+ selected = event_selection(project, paths, event, event_name)
+ if paths and (unsafe_documentation(plan["base"], paths)
+ or unsafe_documentation(plan["testedMergeSHA"], paths)):
+ selected = event_selection(project, ["unknown"], event, event_name)
+ plan["reason"] = "non-regular documentation input: conservative mainline coverage"
+ if event_name in {"schedule", "workflow_dispatch"}:
+ plan["reason"] = "weekly full coverage" if event_name == "schedule" else "manual " + event.get(
+ "inputs", {}).get("test-set", "mainline")
+ except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, AttributeError):
+ selected = event_selection(project, ["unknown"], {"ref": event.get("ref")},
+ "push" if event_name == "push" else "pull_request")
+ plan["reason"] = "verification unavailable: conservative mainline coverage"
+ if not selected:
+ plan["reason"] = "plain prose documentation or maintenance inputs; tests unaffected"
+ plan["selected"] = sorted(selected)
+ plan["expected"] = suites(project, selected)
+ for module in MODULES[project]:
+ plan[module] = module in selected
+ plan["pd_store"] = any(plan.get(m, False) for m in ["pd", "store", "hstore", "struct"])
+ plan["needsFixture"] = project == "toolchain" and bool(selected)
+ plan["fixture"] = plan["needsFixture"]
+ if project == "toolchain":
+ plan["security"] = bool(selected) or any(p.startswith(".github/workflows/codeql") for p in paths)
+ plan["required"] = []
+ plan["security_languages"] = json.dumps(["java"])
+ plan["changedPaths"] = paths
+ plan["selectionReasons"] = {
+ suite: [path for path in paths if suite in suites(project, event_selection(project, [path], event, event_name))]
+ for suite in plan["expected"]
+ }
+ if os.environ.get("GITHUB_STEP_SUMMARY"):
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as out:
+ out.write("## Selected CI checks\n" + selection_table(plan) + "\n")
+ return plan
+
+def selection_reason(plan, suite):
+ paths = plan.get("selectionReasons", {}).get(suite, [])
+ if not paths:
+ selected = suite in plan["expected"] or suite in plan["selected"]
+ return plan.get("reason", "affected inputs") if selected else "no affected inputs"
+ # Bound the summary size; paths in the plan artifact retain the full explanation.
+ shown = ["`" + path.replace("`", "\\`").replace("|", "\\|").replace("\n", " ") + "`"
+ for path in paths[:3]]
+ suffix = f" (+{len(paths) - 3} more)" if len(paths) > 3 else ""
+ return ", ".join(shown) + suffix
+
+
+def selection_table(plan):
+ summary = "| Check | Selected | Merge requirement | Changed inputs |\n| --- | --- | --- | --- |\n"
+ checks = suites(plan["project"], set(MODULES[plan["project"]]))
+ for suite in checks:
+ chosen = suite in plan["expected"]
+ role = "advisory"
+ summary += f"| {suite} | {'yes' if chosen else 'no'} | {role} | {selection_reason(plan, suite)} |\n"
+ return summary
+
+
+class GateFailure(ValueError):
+ def __init__(self, message, report):
+ super().__init__(message)
+ self.report = report
+
+
+def gate(plan, results, fetch=None):
+ """Keep the Toolchain result summary and current-PR gate contract."""
+ expected = list(plan["expected"])
+ report = {key: plan[key] for key in ["schema", "repository", "project", "pr", "source", "branch", "base",
+ "head", "testedMergeSHA", "selected"]}
+ report["selectionReasons"] = plan.get("selectionReasons", {})
+ report.update(runID=int(os.environ.get("GITHUB_RUN_ID", "0")),
+ runAttempt=int(os.environ.get("GITHUB_RUN_ATTEMPT", "1")), executed=expected,
+ results={suite: results.get(suite, {}).get("result", "missing") for suite in expected})
+ errors = []
+ if results.get("plan", {}).get("result") != "success":
+ errors.append("planner did not succeed")
+ errors.extend("selected suite did not succeed: " + suite for suite in expected
+ if report["results"][suite] != "success")
+ if plan["project"] == "toolchain":
+ if set(expected).intersection({"client", "loader", "tools", "spark", "go"}):
+ if results.get("fixture", {}).get("result") != "success":
+ errors.append("selected tests lack successful fixture")
+ if "hubble" in expected and results.get("hubble-fixture", {}).get("result") != "success":
+ errors.append("selected Hubble tests lack successful baseline fixture")
+ summary = "Selection: " + plan.get("reason", "affected inputs") + "\n"
+ summary += "Tested base: `" + plan["base"] + "`; merge: `" + plan["testedMergeSHA"] + "`\n\n"
+ summary += "| Check | Selected | Merge requirement | Changed inputs | Result |\n| --- | --- | --- | --- | --- |\n"
+ for suite in suites(plan["project"], set(MODULES[plan["project"]])):
+ chosen = suite in expected
+ status = report["results"].get(suite, "not selected")
+ summary += f"| {suite} | {'yes' if chosen else 'no'} | advisory | {selection_reason(plan, suite)} | {status} |\n"
+ if os.environ.get("GITHUB_STEP_SUMMARY"):
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as out:
+ out.write("## Affected module results\n" + summary + "\n")
+ print(summary)
+ if errors:
+ raise GateFailure("; ".join(errors), report)
+ if plan["project"] == "toolchain":
+ require_current_pr(plan, fetch or api)
+ return report
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest="command", required=True)
+ p = sub.add_parser("plan")
+ p.add_argument("--project", choices=MODULES, required=True)
+ p.add_argument("--event-path", required=True)
+ p.add_argument("--repository", required=True)
+ p.add_argument("--output", required=True)
+ p.add_argument("--event-name")
+ p = sub.add_parser("gate")
+ p.add_argument("--plan", required=True)
+ p.add_argument("--results", required=True)
+ p.add_argument("--mode", choices=["all"], default="all")
+ p.add_argument("--output", required=True)
+ args = parser.parse_args()
+ if args.command == "plan":
+ value = create_plan(args.project, json.loads(Path(args.event_path).read_text()), args.repository,
+ event_name=args.event_name)
+ if os.environ.get("GITHUB_OUTPUT"):
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
+ for key, item in value.items():
+ if isinstance(item, bool):
+ output.write(f"{key}={str(item).lower()}\n")
+ output.write(f"security_languages={value['security_languages']}\nplan_file={args.output}\n")
+ else:
+ try:
+ value = gate(json.loads(Path(args.plan).read_text()), json.loads(Path(args.results).read_text()))
+ except GateFailure as error:
+ Path(args.output).write_text(json.dumps(error.report, indent=2) + "\n")
+ raise
+ Path(args.output).write_text(json.dumps(value, indent=2) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/docker-deployment.py b/.github/scripts/docker-deployment.py
new file mode 100644
index 0000000000..12b253ec2f
--- /dev/null
+++ b/.github/scripts/docker-deployment.py
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Start the four locally built PR images using the shipped Compose files."""
+
+import base64
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+
+
+ADMIN_PASSWORD = "ci-compose-password"
+PD_PASSWORD = "ci-compose-pd-secret"
+
+
+def command(*args):
+ return subprocess.check_output(args, text=True, timeout=60).strip()
+
+
+def verify_image(container, image, expected):
+ actual = command("docker", "inspect", "--format", "{{.Image}}", container)
+ if not expected or actual != expected:
+ raise RuntimeError(f"Container {container} uses {actual}; expected {image} ({expected})")
+
+
+def response(url, credentials=None):
+ request = urllib.request.Request(url)
+ if credentials:
+ encoded = base64.b64encode(credentials.encode()).decode()
+ request.add_header("Authorization", "Basic " + encoded)
+ try:
+ with urllib.request.urlopen(request, timeout=15) as result:
+ return result.status, result.read().decode()
+ except urllib.error.HTTPError as error:
+ return error.code, error.read().decode()
+
+
+def expect_response(url, expected=200, credentials=None):
+ actual, body = response(url, credentials)
+ if actual != expected:
+ raise RuntimeError(f"{url}: HTTP {actual}, expected {expected}")
+ return body
+
+
+def expected_versions():
+ root = Path(__file__).resolve().parents[2]
+ ns = {"m": "http://maven.apache.org/POM/4.0.0"}
+ revision = ET.parse(root / "pom.xml").findtext("m:properties/m:revision", namespaces=ns)
+ gremlin = ET.parse(root / "hugegraph-server/pom.xml").findtext(
+ "m:properties/m:tinkerpop.version", namespaces=ns)
+ properties = dict(line.split("=", 1) for line in
+ (root / "hugegraph-commons/hugegraph-common/src/main/resources/version.properties")
+ .read_text().splitlines() if "=" in line and not line.startswith("#"))
+ if properties["VersionInBash"] != revision:
+ raise RuntimeError("VersionInBash does not match the project revision")
+ # Packaged API classes use their manifest version before the resource fallback.
+ api = ET.parse(root / "hugegraph-server/hugegraph-api/pom.xml").findtext(
+ ".//m:manifestEntries/m:Implementation-Version", namespaces=ns) or properties["ApiVersion"]
+ expected = {"version": "v1", "core": revision, "gremlin": gremlin, "api": api}
+ if any(not isinstance(value, str) or not value for value in expected.values()):
+ raise RuntimeError("Source POMs did not define non-empty expected versions")
+ return expected
+
+
+def verify_versions(payload):
+ versions = payload.get("versions") if isinstance(payload, dict) else None
+ expected = expected_versions()
+ if not isinstance(versions, dict) or any(versions.get(key) != value for key, value in expected.items()):
+ raise RuntimeError(f"Server did not return the expected versions object: {expected}; got {versions}")
+
+
+def verify_server():
+ url = "http://localhost:8080"
+ expect_response(url + "/graphspaces/DEFAULT/graphs", 401)
+ graphs = json.loads(expect_response(url + "/graphspaces/DEFAULT/graphs",
+ credentials=f"admin:{ADMIN_PASSWORD}"))
+ names = graphs.get("graphs") if isinstance(graphs, dict) else None
+ if not isinstance(names, list) or "hugegraph" not in names:
+ raise RuntimeError("Server did not return its initialized hugegraph in the graphs array")
+ payload = json.loads(expect_response(url + "/versions"))
+ verify_versions(payload)
+
+
+def verify_storage():
+ for port in (8620, 8520):
+ if expect_response(f"http://localhost:{port}/v1/health") != "":
+ raise RuntimeError(f"Port {port} did not return the PD/Store health response")
+ ready = json.loads(expect_response("http://localhost:8620/v1/ready"))
+ if not isinstance(ready, dict) or ready.get("ready") is not True:
+ raise RuntimeError("PD did not report ready=true")
+ url = "http://localhost:8620/v1/stores"
+ expect_response(url, 401)
+ payload = json.loads(expect_response(url, credentials=f"hg:{PD_PASSWORD}"))
+ data = payload.get("data") if isinstance(payload, dict) else None
+ if (not isinstance(data, dict) or payload.get("status") != 0 or
+ not isinstance(data.get("stores"), list) or not data["stores"]):
+ raise RuntimeError("Authenticated PD did not return its registered stores array")
+
+
+def verify_graph(run_id):
+ root = Path(__file__).resolve().parents[2]
+ script = root / "hugegraph-server/hugegraph-dist/src/assembly/travis/run-server-e2e-smoke-test.sh"
+ environment = dict(os.environ, HUGEGRAPH_USERNAME="admin", HUGEGRAPH_PASSWORD=ADMIN_PASSWORD)
+ # Reuse the write/read/Gremlin assertions; allow its readiness and bounded HTTP requests.
+ subprocess.run(["bash", str(script), "http://localhost:8080", "create", run_id],
+ check=True, timeout=960, env=environment)
+
+
+def smoke(tag, topology, images):
+ root = Path(__file__).resolve().parents[2]
+ project = f"hg-pr-{tag}-{Path(topology).stem}"
+ compose = ["docker", "compose", "-p", project, "-f", str(root / "docker" / topology)]
+ # Capture identities before startup so a tag change cannot bless an unrelated container.
+ expected = {service: command("docker", "image", "inspect", "--format", "{{.Id}}",
+ f"{repository}:{tag}") for service, repository in images.items()}
+ failed = False
+ try:
+ subprocess.run(compose + ["up", "-d", "--pull", "never", "--wait",
+ "--wait-timeout", "600"] + list(images), check=True, timeout=660)
+ for service, repository in images.items():
+ container = command(*compose, "ps", "-q", service)
+ if not container or "\n" in container:
+ raise RuntimeError(f"Expected exactly one {service} container")
+ verify_image(container, f"{repository}:{tag}", expected[service])
+ health = command("docker", "inspect", "--format", "{{.State.Health.Status}}", container)
+ if health != "healthy":
+ raise RuntimeError(f"{service} is {health}")
+ verify_server()
+ if "pd" in images:
+ verify_storage()
+ verify_graph(project.replace("-", "_"))
+ except BaseException:
+ failed = True
+ for args in (["ps"], ["logs", "--no-color", "--tail", "200"]):
+ try:
+ subprocess.run(compose + args, check=False, timeout=60)
+ except (OSError, subprocess.SubprocessError) as error:
+ print(f"Could not collect Compose diagnostics: {error}", file=sys.stderr)
+ raise
+ finally:
+ try:
+ subprocess.run(compose + ["down", "-v", "--remove-orphans"], check=True, timeout=120)
+ except (OSError, subprocess.SubprocessError) as error:
+ if not failed:
+ raise
+ print(f"Compose cleanup also failed: {error}", file=sys.stderr)
+
+
+def main(tag):
+ if not tag.startswith("ci-") or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789-" for c in tag):
+ raise ValueError("Use a unique ci- image tag")
+ os.environ.update(HUGEGRAPH_VERSION=tag, HUGEGRAPH_PULL_POLICY="never",
+ HUGEGRAPH_ADMIN_PASSWORD=ADMIN_PASSWORD,
+ HUGEGRAPH_AUTH_TOKEN_SECRET="0123456789abcdef" * 4,
+ HG_PD_AUTH_SECRET_KEY=PD_PASSWORD)
+ smoke(tag, "docker-compose.yml", {"server": "hugegraph/hugegraph"})
+ smoke(tag, "docker-compose-hstore.yml", {"pd": "hugegraph/pd", "store": "hugegraph/store",
+ "server": "hugegraph/server"})
+
+
+if __name__ == "__main__":
+ main(sys.argv[1])
diff --git a/.github/scripts/test_ci_policy.py b/.github/scripts/test_ci_policy.py
new file mode 100644
index 0000000000..65e7666324
--- /dev/null
+++ b/.github/scripts/test_ci_policy.py
@@ -0,0 +1,521 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Path/event selection, real PR snapshots, and coordinator scheduling contracts."""
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import tempfile
+import textwrap
+import unittest
+from unittest.mock import patch
+
+spec = importlib.util.spec_from_file_location("policy", Path(__file__).with_name("ci-policy.py"))
+policy = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(policy)
+
+
+class GitFixture:
+ def __init__(self, root):
+ self.root = root
+ self.git("init", "-q")
+ self.git("config", "user.email", "ci@example.invalid")
+ self.git("config", "user.name", "CI")
+
+ def git(self, *args):
+ return subprocess.check_output(["git", *args], cwd=self.root, text=True).strip()
+
+ def commit(self, path, content, message):
+ file = self.root / path
+ file.parent.mkdir(parents=True, exist_ok=True)
+ file.write_text(content)
+ self.git("add", ".")
+ self.git("commit", "-qm", message)
+ return self.git("rev-parse", "HEAD")
+
+ def plan(self, event, fetch=None, event_name=None):
+ with patch.object(policy, "git", side_effect=self.git), patch.dict(
+ os.environ, {"GITHUB_SHA": self.git("rev-parse", "HEAD"), "GITHUB_EVENT_NAME": ""}):
+ old = os.getcwd()
+ try:
+ os.chdir(self.root)
+ return policy.create_plan("server", event, "apache/server", fetch=fetch, event_name=event_name)
+ finally:
+ os.chdir(old)
+
+
+class PolicyTest(unittest.TestCase):
+ def plan_for(self, paths, event_name="pull_request", event=None):
+ # Identity checks have their own real-Git fixtures; isolate event selection here.
+ def git(*args):
+ if args[0] == "diff":
+ return "\n".join(paths)
+ if args[0] == "show":
+ return "base head"
+ return "merge"
+ event = event or {"before": "base"}
+ if event_name == "pull_request":
+ event = {"pull_request": {"number": 7, "head": {"sha": "head", "ref": "feature",
+ "repo": {"full_name": "alice/server"}}, "base": {"sha": "base"}}}
+ with patch.object(policy, "git", side_effect=git), patch.object(
+ policy, "unsafe_documentation", return_value=False), patch.object(
+ policy, "require_current_pr"), patch.dict(os.environ, {"GITHUB_SHA": "merge"}):
+ return policy.create_plan("server", event, "apache/server", event_name=event_name)
+
+ def test_ordinary_server_changes_only_run_mainline_server_and_arm_smoke(self):
+ for path in ["hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/traversal/A.java",
+ "hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/traversers/A.java",
+ "hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/traversal/A.java"]:
+ with self.subTest(path=path):
+ plan = self.plan_for([path])
+ self.assertEqual({"server", "macos"}, set(plan["selected"]))
+ self.assertEqual({"server_memory", "server_rocksdb", "macos"}, set(plan["expected"]))
+ self.assertFalse(plan["macos_full"])
+ self.assertFalse(plan["pd_store"])
+
+ def test_distributed_server_contracts_keep_hstore_cluster_without_pd_store_units(self):
+ for path in [policy.SERVER_CORE + "backend/store/BackendStore.java",
+ policy.SERVER_CORE + "meta/MetaManager.java", policy.SERVER_CORE + "StandardHugeGraph.java",
+ policy.SERVER_CORE + "task/DistributedTaskScheduler.java", policy.SERVER_CORE + "task/TaskManager.java",
+ policy.SERVER_CORE + "masterelection/GlobalMasterInfo.java",
+ policy.SERVER_API + "core/GraphManager.java",
+ "hugegraph-server/hugegraph-hstore/src/main/A.java"]:
+ with self.subTest(path=path):
+ plan = self.plan_for([path])
+ self.assertTrue({"server", "macos", "hstore", "cluster", "distributed_smoke"}.issubset(plan["selected"]))
+ self.assertTrue(plan["pd_store"])
+ self.assertFalse(plan["pd"])
+ self.assertFalse(plan["store"])
+ self.assertFalse(plan["hbase"])
+ self.assertFalse(plan["riscv64"])
+
+ def test_shared_runtime_consumers_and_protocols(self):
+ for module in ["commons", "struct"]:
+ selected = policy.select("server", [f"hugegraph-{module}/src/main/A.java"])
+ self.assertTrue({"server", "pd", "store", "hstore", "cluster", "macos"}.issubset(selected))
+ self.assertNotIn("hbase", selected)
+ self.assertNotIn("riscv64", selected)
+ for module in ["pd", "store"]:
+ plan = self.plan_for([f"hugegraph-{module}/src/main/A.java"])
+ self.assertTrue(plan["pd_store"])
+ self.assertTrue(plan["hstore"])
+ self.assertTrue(plan["cluster"])
+ self.assertFalse(plan["server"])
+ self.assertFalse(plan["macos"])
+ self.assertTrue({"pd", "store", "hstore", "cluster"}.issubset(policy.select(
+ "server", ["hugegraph-pd/hg-pd-grpc/src/main/proto/common.proto"])))
+
+ def test_noncore_platforms_only_follow_direct_inputs(self):
+ for path in ["hugegraph-server/hugegraph-hbase/src/main/A.java",
+ "hugegraph-server/hugegraph-dist/src/assembly/travis/install-hbase.sh",
+ "hugegraph-server/hugegraph-dist/src/assembly/travis/hbase-ci-site.xml"]:
+ self.assertEqual({"hbase"}, policy.select("server", [path]))
+ self.assertEqual({"riscv64"}, policy.select("server", [".github/workflows/riscv64-ci.yml"]))
+ self.assertTrue(self.plan_for([".github/workflows/server-compatibility-ci.yml"])["riscv64"])
+ self.assertTrue(self.plan_for([".github/workflows/server-tests.yml"])["hbase"])
+ self.assertTrue(self.plan_for(["hugegraph-server/hugegraph-rocksdb/pom.xml"])["riscv64"])
+ self.assertTrue(self.plan_for(["hugegraph-server/hugegraph-dist/pom.xml"])["riscv64"])
+ for path in ["hugegraph-server/hugegraph-rocksdb/src/main/A.java",
+ "hugegraph-server/hugegraph-dist/src/assembly/static/bin/start-hugegraph.sh"]:
+ plan = self.plan_for([path])
+ self.assertTrue(plan["macos"])
+ self.assertTrue(plan["macos_full"])
+ self.assertFalse(plan["hbase"])
+ self.assertEqual(path.startswith("hugegraph-server/hugegraph-dist/src/assembly/static/bin/"),
+ plan["riscv64"])
+ for path in ["docker/hbase/Dockerfile", "docker/hbase/entrypoint.sh", policy.TRAVIS + "install-backend.sh"]:
+ self.assertTrue(self.plan_for([path])["hbase"])
+ install = self.plan_for([policy.TRAVIS + "install-backend.sh"])
+ self.assertTrue(install["hstore"])
+ java = self.plan_for([".github/workflows/.java-version"])
+ self.assertTrue(java["macos_full"])
+ self.assertTrue(java["hbase"])
+ self.assertTrue(java["riscv64"])
+ for path in policy.PLATFORM_POMS:
+ self.assertTrue(self.plan_for([path])["riscv64"])
+ self.assertTrue(self.plan_for([path])["macos_full"])
+ for path in ["hugegraph-server/hugegraph-core/src/main/resources/proto/A.proto",
+ "hugegraph-pd/hg-pd-grpc/src/main/proto/A.proto"]:
+ self.assertTrue(self.plan_for([path])["riscv64"])
+ self.assertFalse(self.plan_for(["hugegraph-store/hg-store-grpc/src/main/proto/A.proto"])["riscv64"])
+ self.assertFalse(self.plan_for(["hugegraph-pd/hg-pd-core/pom.xml"])["riscv64"])
+ native = self.plan_for(["hugegraph-server/hugegraph-dist/src/assembly/travis/run-native-runtime-smoke-test.sh"])
+ self.assertTrue(native["macos_full"])
+ self.assertTrue(native["riscv64"])
+ self.assertTrue(self.plan_for([policy.TRAVIS + "check-rocksdb-only-dist.sh"])["riscv64"])
+
+ def test_security_tracks_sensitive_inputs_and_dependencies(self):
+ paths = [policy.SERVER_CORE + "auth/AuthManager.java", policy.SERVER_CORE + "security/HugeSecurityManager.java",
+ policy.SERVER_CORE + "config/AuthOptions.java",
+ policy.SERVER_API + "api/filter/AuthenticationFilter.java", "hugegraph-pd/hg-pd-core/pom.xml",
+ ".github/workflows/codeql-analysis.yml",
+ policy.SERVER_CORE + "backend/cache/CachedSchemaTransaction.java",
+ policy.SERVER_CORE + "backend/cache/CachedSchemaTransactionV2.java"]
+ for path in paths:
+ with self.subTest(path=path):
+ self.assertTrue(self.plan_for([path])["security"])
+ for path in [policy.SERVER_CORE + "api/Author.java", "hugegraph-pd/hg-pd-core/src/main/A.java",
+ "docker/Dockerfile", "helm/templates/service.yaml",
+ policy.SERVER_CORE + "backend/cache/CacheManager.java",
+ policy.SERVER_CORE + "backend/cache/CachedSchemaTransactionTest.java"]:
+ self.assertFalse(self.plan_for([path])["security"])
+
+ def test_documentation_and_nested_readmes_skip_every_test_lane(self):
+ paths = ["README.md", "CONTRIBUTING.md", "notes.txt", "LICENSE", "NOTICE", "docs/ci.md",
+ ".github/outdated/APACHE_PROPOSAL.md", "hugegraph-pd/design.md",
+ "hugegraph-pd/docs/raft.md", "hugegraph-store/docs/guide.txt", "hugegraph-server/hugegraph-hstore/README.md",
+ "hugegraph-server/nested/component/README.md", "hugegraph-pd/hg-pd-core/AGENTS.md", "docker/README.md"]
+ for event_name, event in [("pull_request", {}), ("push", {"ref": "refs/heads/master"})]:
+ plan = self.plan_for(paths, event_name, event)
+ self.assertEqual([], plan["selected"])
+ self.assertEqual([], plan["expected"])
+ self.assertFalse(any(plan[module] for module in policy.MODULES["server"]))
+ for path in ["hugegraph-server/hugegraph-test/src/test/resources/README.md", "docs/example.java",
+ "docs/config.yml", "hugegraph-pd/docs/fixtures/example.txt", "helm/templates/README.md",
+ "hugegraph-store/hg-store-dist/src/assembly/static/conf/startup.txt"]:
+ with self.subTest(path=path):
+ self.assertTrue(policy.select("server", [path]))
+
+ def test_master_push_runs_smoke_without_repeated_full_suites(self):
+ event = {"before": "base", "ref": "refs/heads/master"}
+ generic = self.plan_for(["hugegraph-server/hugegraph-core/src/main/A.java"], "push", event)
+ self.assertEqual({"macos", "distributed_smoke"}, set(generic["selected"]))
+ pd = self.plan_for(["hugegraph-pd/hg-pd-core/src/main/A.java"], "push", event)
+ self.assertEqual({"distributed_smoke"}, set(pd["selected"]))
+ for path in ["hugegraph-server/hugegraph-hbase/src/main/A.java", ".github/workflows/riscv64-ci.yml",
+ ".github/workflows/codeql-analysis.yml"]:
+ self.assertEqual([], self.plan_for([path], "push", event)["expected"])
+ native = self.plan_for(["hugegraph-server/hugegraph-rocksdb/src/main/A.java"], "push", event)
+ self.assertTrue(native["macos"])
+ self.assertFalse(native["macos_full"])
+ self.assertTrue(self.plan_for(["docker/Dockerfile"], "push", event)["docker"])
+
+ def test_manual_presets_weekly_and_release_branches(self):
+ for preset, selected in policy.PRESETS.items():
+ with self.subTest(preset=preset):
+ plan = self.plan_for([], "workflow_dispatch", {"inputs": {"test-set": preset}})
+ self.assertEqual(selected, set(plan["selected"]))
+ default = self.plan_for([], "workflow_dispatch")
+ self.assertEqual(policy.MAINLINE, set(default["selected"]))
+ self.assertFalse(default["macos_full"])
+ weekly = self.plan_for([], "schedule")
+ self.assertEqual(set(policy.MODULES["server"]), set(weekly["selected"]))
+ self.assertTrue(weekly["macos_full"])
+ for branch in ["release-1.7", "test-ci", "tinkerpop-3.8"]:
+ plan = self.plan_for(["hugegraph-server/hugegraph-core/src/main/A.java"], "push",
+ {"before": "base", "ref": "refs/heads/" + branch})
+ self.assertTrue(plan["server"])
+ self.assertFalse(plan["hbase"])
+ self.assertFalse(plan["riscv64"])
+
+ def test_unknown_inputs_fall_back_to_mainline_without_expensive_noncore(self):
+ for path in [".github/scripts/ci-policy.py", ".mvn/maven.config", "unknown"]:
+ self.assertEqual(policy.MAINLINE, policy.select("server", [path]))
+ with patch.object(policy, "git", side_effect=lambda *args: "merge" if args[0] == "rev-parse" else
+ (_ for _ in ()).throw(subprocess.CalledProcessError(1, "git"))):
+ plan = policy.create_plan("server", {"before": "base"}, "apache/server", event_name="push")
+ self.assertEqual(policy.MAINLINE, set(plan["selected"]))
+ self.assertFalse(plan["macos_full"])
+ self.assertFalse(plan["hbase"])
+ self.assertFalse(plan["riscv64"])
+
+ def test_deployment_and_specific_workflows_keep_specific_owners(self):
+ for path in [".github/workflows/docker-build-ci.yml", ".github/scripts/check-docker-images.sh",
+ ".github/scripts/docker-deployment.py", ".github/scripts/test_docker_deployment.py",
+ "hugegraph-server/hugegraph-dist/docker/Dockerfile"]:
+ self.assertEqual({"docker"}, policy.select("server", [path]))
+ for path in [".asf.yaml", ".github/dependabot.yml", ".editorconfig"]:
+ self.assertEqual(set(), policy.select("server", [path]))
+ for module in ["pd", "store"]:
+ selected = policy.select("server", [f"hugegraph-{module}/hg-{module}-dist/src/assembly/static/bin/start.sh"])
+ self.assertTrue({module, "hstore", "cluster", "docker", "distributed_smoke"}.issubset(selected))
+ self.assertNotIn("server", selected)
+ version = "hugegraph-commons/hugegraph-common/src/main/resources/version.properties"
+ self.assertTrue({"commons", "docker"}.issubset(policy.select("server", [version])))
+
+ def test_toolchain_selection_and_fixture_gate_contract_remain_compatible(self):
+ quiet = patch("builtins.print")
+ quiet.start()
+ self.addCleanup(quiet.stop)
+ self.assertEqual({"client", "loader", "tools", "spark", "hubble"}, policy.select(
+ "toolchain", ["hugegraph-client/src/A.java"]))
+ self.assertEqual({"loader", "hubble"}, policy.select("toolchain", ["hugegraph-loader/src/A.java"]))
+ self.assertEqual(set(policy.MODULES["toolchain"]), policy.select("toolchain", ["hugegraph-client/api.proto"]))
+ self.assertIn("go", policy.select("toolchain", ["hugegraph-client/assembly/travis/start-hugegraph-servers.sh"]))
+ plan = {"schema": 1, "repository": "apache/toolchain", "project": "toolchain", "pr": 0,
+ "source": "apache/toolchain", "branch": "master", "base": "base", "head": "head",
+ "testedMergeSHA": "head", "selected": ["client"], "expected": ["client"]}
+ results = {"plan": {"result": "success"}, "client": {"result": "success"}, "fixture": {"result": "success"}}
+ self.assertEqual(["client"], policy.gate(plan, results, lambda _: self.fail("No post-run API query"))["executed"])
+ for state in ["failure", "cancelled", "skipped", None]:
+ for job in ["plan", "client", "fixture"]:
+ with self.subTest(state=state, job=job), self.assertRaises(policy.GateFailure):
+ policy.gate(plan, dict(results, **{job: {"result": state}}))
+
+ def test_toolchain_gate_keeps_summary_current_pr_and_cli_all(self):
+ plan = {"schema": 1, "repository": "apache/toolchain", "project": "toolchain", "pr": 7,
+ "source": "alice/toolchain", "branch": "feature", "base": "base", "head": "head",
+ "testedMergeSHA": "merge", "selected": ["client"], "expected": ["client"]}
+ live = {"state": "open", "head": {"sha": "head", "ref": "feature", "repo": {"full_name": "alice/toolchain"}},
+ "base": {"repo": {"full_name": "apache/toolchain"}}}
+ results = {"plan": {"result": "success"}, "client": {"result": "success"}, "fixture": {"result": "success"}}
+ with tempfile.TemporaryDirectory() as directory, patch("builtins.print"):
+ root = Path(directory)
+ summary = root / "summary.md"
+ with patch.dict(os.environ, {"GITHUB_STEP_SUMMARY": str(summary)}):
+ policy.gate(plan, results, lambda _: live)
+ self.assertIn("| client | yes | advisory | affected inputs | success |", summary.read_text())
+ with self.assertRaises(policy.StaleInputError):
+ policy.gate(plan, results, lambda _: dict(live, head=dict(live["head"], sha="new-head")))
+ (root / "plan.json").write_text(json.dumps(dict(plan, pr=0)))
+ (root / "results.json").write_text(json.dumps(results))
+ outcome = subprocess.run(["python3", str(Path(policy.__file__).resolve()), "gate", "--mode", "all",
+ "--plan", str(root / "plan.json"), "--results", str(root / "results.json"),
+ "--output", str(root / "report.json")], capture_output=True, text=True)
+ self.assertEqual(0, outcome.returncode, outcome.stderr)
+ self.assertEqual(["client"], json.loads((root / "report.json").read_text())["executed"])
+
+ def test_summary_has_only_advisory_checks(self):
+ with tempfile.TemporaryDirectory() as directory, patch.dict(
+ os.environ, {"GITHUB_STEP_SUMMARY": str(Path(directory) / "summary.md")}):
+ plan = self.plan_for(["pom.xml"])
+ summary = (Path(directory) / "summary.md").read_text()
+ self.assertIn("| server_memory | yes | advisory |", summary)
+ self.assertNotIn("| required |", summary)
+ self.assertEqual([], plan["required"])
+ self.assertEqual(1, summary.count("| dependency_license |"))
+
+ def coordinator_jobs(self, plan, selection="success", java="success", result="success", cancelled=False):
+ workflow = Path(__file__).resolve().parents[1] / "workflows/server-ci.yml"
+ text = workflow.read_text().split("\njobs:\n", 1)[1]
+ jobs = {}
+ for match in re.finditer(r"^ ([\w-]+):\n(.*?)(?=^ [\w-]+:\n|\Z)", text, re.M | re.S):
+ name, body = match.groups()
+ if name == "preflight":
+ continue
+ condition = re.search(r"^ if: \$\{\{ (.+) \}\}$", body, re.M)
+ self.assertIsNotNone(condition, "Caller needs an explicit cancellation/selection condition: " + name)
+ expression = condition.group(1)
+ # GitHub otherwise injects success(), which would hide tests after a license failure.
+ self.assertIn("cancelled()", expression)
+ outputs = {key: str(value).lower() for key, value in plan.items() if isinstance(value, bool)}
+ outputs.update(selection_result=selection, java_result=java)
+ expression = re.sub(r"needs\.preflight\.outputs\.([\w]+)",
+ lambda match: repr(outputs.get(match.group(1), "")), expression)
+ expression = expression.replace("needs.preflight.result", repr(result))
+ expression = expression.replace("!cancelled()", "not cancelled")
+ expression = expression.replace("&&", "and").replace("||", "or")
+ jobs[name] = bool(eval(expression, {"__builtins__": {}}, {"cancelled": cancelled}))
+ return {job for job, run in jobs.items() if run}
+
+ def test_real_coordinator_keeps_selected_jobs_after_license_or_selftest_failure(self):
+ workflow = (Path(__file__).resolve().parents[1] / "workflows/server-ci.yml").read_text()
+ preflight = re.search(r"^ preflight:\n(.*?)(?=^ [\w-]+:|\Z)", workflow, re.M | re.S).group(1)
+ self.assertEqual("check-license", re.search(r"^ name: (.+)$", preflight, re.M).group(1))
+ full = self.plan_for([], "workflow_dispatch", {"inputs": {"test-set": "full"}})
+ expected = {"build-server", "server-rocksdb", "commons", "pd_store", "cluster", "docker", "helm",
+ "dependency_license", "compatibility", "codeql"}
+ self.assertEqual(expected, self.coordinator_jobs(full))
+ self.assertEqual(expected, self.coordinator_jobs(full, result="failure"))
+ docs = self.plan_for(["hugegraph-pd/docs/guide.txt", "docker/README.md"])
+ self.assertEqual(set(), self.coordinator_jobs(docs, result="failure"))
+ source = self.plan_for(["hugegraph-server/hugegraph-core/src/main/A.java"])
+ self.assertEqual({"build-server", "server-rocksdb", "compatibility"}, self.coordinator_jobs(source))
+ master = self.plan_for(["hugegraph-server/hugegraph-core/src/main/A.java"], "push",
+ {"before": "base", "ref": "refs/heads/master"})
+ self.assertEqual({"docker", "compatibility"}, self.coordinator_jobs(master))
+
+ def test_real_coordinator_blocks_selection_runtime_failure_and_cancellation(self):
+ full = self.plan_for([], "schedule")
+ for state in ["failure", "cancelled", "skipped", ""]:
+ with self.subTest(selection=state):
+ self.assertEqual(set(), self.coordinator_jobs(full, selection=state, result="failure"))
+ with self.subTest(java=state):
+ self.assertEqual({"helm"}, self.coordinator_jobs(full, java=state, result="failure"))
+ self.assertEqual(set(), self.coordinator_jobs(full, cancelled=True))
+
+ def test_contract_scripts_reach_their_actual_inner_jobs_and_steps(self):
+ workflows = Path(__file__).resolve().parents[1] / "workflows"
+ cases = [("test-stop-hugegraph-store.sh", {"struct"}),
+ ("test-check-java-runtime-logs.sh", {"struct"}),
+ ("test-check-jacoco-report.sh", {"struct"}),
+ ("test-codecov-upload-config.sh", {"struct"}),
+ ("test-riscv64-java-runtime.sh", {"server", "riscv64"})]
+ for name, suites in cases:
+ script = policy.TRAVIS + name
+ with self.subTest(script=script):
+ plan = self.plan_for([script])
+ self.assertEqual(suites, set(plan["selected"]))
+ callers = self.coordinator_jobs(plan)
+ if "struct" in suites:
+ self.assertEqual({"pd_store"}, callers)
+ root = (workflows / "server-ci.yml").read_text()
+ caller = re.search(r"^ pd_store:\n(.*?)(?=^ [\w-]+:|\Z)", root, re.M | re.S).group(1)
+ inputs = {}
+ for key in ["struct", "store"]:
+ binding = re.search(r"^ run-" + key + r": \$\{\{ (.+) \}\}$", caller, re.M).group(1)
+ binding = binding.replace("needs.preflight.outputs." + key, repr(str(plan[key]).lower()))
+ inputs[key] = eval(binding, {"__builtins__": {}})
+ workflow = (workflows / "pd-store-ci.yml").read_text()
+ struct = workflow.split("\n struct:\n", 1)[1].split("\n pd:\n", 1)[0]
+ condition = re.search(r"^ if: (.+)$", struct, re.M).group(1)
+ condition = condition.replace("inputs.run-struct", str(inputs["struct"]))
+ condition = condition.replace("inputs.run-store", str(inputs["store"]))
+ self.assertTrue(eval(condition.replace("||", "or"), {"__builtins__": {}}))
+ step = next(step for step in struct.split("\n - ") if Path(script).name in step)
+ self.assertNotRegex(step, r"(?m)^ if:")
+ else:
+ self.assertIn("server-rocksdb", callers)
+ workflow = (workflows / "server-ci.yml").read_text()
+ # Read the backend from the actual caller rather than assuming a preset.
+ caller = re.search(r"^ server-rocksdb:\n(.*?)(?=^ [\w-]+:|\Z)",
+ workflow, re.M | re.S).group(1)
+ backend = re.search(r"^ backend: (\w+)$", caller, re.M).group(1)
+ inner = (workflows / "server-tests.yml").read_text()
+ step = next(step for step in inner.split("\n - ") if Path(script).name in step)
+ condition = re.search(r"^ if: (.+)$", step, re.M).group(1)
+ self.assertTrue(eval(condition.replace("inputs.backend", repr(backend)),
+ {"__builtins__": {}}))
+ mixed = self.plan_for([policy.TRAVIS + "test-stop-hugegraph-store.sh", "hugegraph-pd/src/A.java"])
+ self.assertTrue({"struct", "pd", "store", "hstore", "cluster"}.issubset(mixed["selected"]))
+ checker = self.plan_for([policy.TRAVIS + "check-java-runtime-logs.sh"])
+ self.assertEqual({"build-server", "server-rocksdb", "commons", "pd_store", "cluster", "compatibility"},
+ self.coordinator_jobs(checker))
+ self.assertTrue(checker["hbase"] and checker["riscv64"] and checker["macos_full"])
+ master = {"ref": "refs/heads/master"}
+ for script in policy.CONTRACT_SUITES:
+ self.assertEqual([], self.plan_for([script], "push", master)["expected"])
+ runtime = "hugegraph-server/hugegraph-core/src/main/A.java"
+ self.assertEqual(self.plan_for([runtime], "push", master)["selected"],
+ self.plan_for([runtime, policy.TRAVIS + "check-java-runtime-logs.sh"],
+ "push", master)["selected"])
+
+ def test_coordinator_reads_and_validates_real_java_input(self):
+ workflow = (Path(__file__).resolve().parents[1] / "workflows/server-ci.yml").read_text()
+ code = workflow.split("python3 - <<'PYTHON'\n", 1)[1].split("\n PYTHON", 1)[0]
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ java = root / ".github/workflows/.java-version"
+ java.parent.mkdir(parents=True)
+ output = root / "outputs.txt"
+ for version in ["17", "17.0.20", "21.0.1", "", "bad", '17\n21']:
+ java.write_text(version)
+ result = subprocess.run(["python3", "-c", textwrap.dedent(code)], cwd=root,
+ env={**os.environ, "GITHUB_OUTPUT": str(output)}, capture_output=True, text=True)
+ with self.subTest(version=version):
+ self.assertEqual(version in {"17", "17.0.20", "21.0.1"}, result.returncode == 0, result.stderr)
+ self.assertEqual(['versions=["17"]', 'versions=["17.0.20"]', 'versions=["21.0.1"]'],
+ output.read_text().splitlines())
+
+ def test_real_git_document_modes_renames_and_cli_outputs(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ fixture = GitFixture(root)
+ base = fixture.commit("README.md", "base", "base")
+ fixture.commit("README.md", "docs", "docs")
+ self.assertEqual([], fixture.plan({"before": base, "ref": "refs/heads/master"})["expected"])
+ (root / "README.md").chmod(0o755)
+ fixture.git("add", ".")
+ fixture.git("commit", "-qm", "executable prose")
+ unsafe = fixture.plan({"before": base, "ref": "refs/heads/feature"})
+ self.assertEqual(policy.MAINLINE, set(unsafe["selected"]))
+ (root / "README.md").unlink()
+ (root / "README.md").symlink_to("target.txt")
+ fixture.git("add", ".")
+ fixture.git("commit", "-qm", "symlink prose")
+ self.assertEqual(policy.MAINLINE, set(fixture.plan({"before": base})["selected"]))
+ before = fixture.commit("hugegraph-server/A.java", "source", "source")
+ (root / "docs").mkdir()
+ fixture.git("mv", "hugegraph-server/A.java", "docs/renamed.md")
+ fixture.git("commit", "-qm", "rename source to prose")
+ self.assertTrue(fixture.plan({"before": before})["server"])
+ event = root / "event.json"
+ event.write_text("{}")
+ output = root / "outputs.txt"
+ subprocess.run(["python3", str(Path(policy.__file__).resolve()), "plan", "--project", "server",
+ "--repository", "apache/server", "--event-path", str(event), "--event-name", "schedule",
+ "--output", str(root / "plan.json")], cwd=root, check=True,
+ env={**os.environ, "GITHUB_OUTPUT": str(output)})
+ self.assertIn("macos_full=true\n", output.read_text())
+ self.assertIn("pd_store=true\n", output.read_text())
+ self.assertNotIn("compatibility=", output.read_text())
+ self.assertEqual(set(policy.MODULES["server"]), set(json.loads((root / "plan.json").read_text())["selected"]))
+
+ def test_cumulative_pr_snapshot_keeps_source_after_document_commit_and_checks_current_head(self):
+ with tempfile.TemporaryDirectory() as directory:
+ fixture = GitFixture(Path(directory))
+ base = fixture.commit("README.md", "base", "base")
+ fixture.git("checkout", "-qb", "feature")
+ fixture.commit("hugegraph-server/A.java", "source", "source")
+ head = fixture.commit("README.md", "docs after source", "docs")
+ merge = fixture.git("commit-tree", fixture.git("rev-parse", "HEAD^{tree}"),
+ "-p", base, "-p", head, "-m", "PR merge")
+ fixture.git("checkout", "--detach", "-q", merge)
+ live = {"number": 7, "state": "open", "head": {"sha": head, "ref": "feature",
+ "repo": {"full_name": "alice/server"}}, "base": {"sha": base, "repo": {"full_name": "apache/server"}}}
+ calls = []
+ def fetch(path):
+ calls.append(path)
+ return live
+ plan = fixture.plan({"pull_request": live}, fetch)
+ self.assertEqual(["repos/apache/server/pulls/7"], calls)
+ self.assertTrue(plan["server"])
+ self.assertFalse(plan["pd_store"])
+ self.assertFalse(plan["security"])
+ self.assertIn("hugegraph-server/A.java", plan["changedPaths"])
+ for section, field, value in [("head", "sha", "new-head"), ("head", "ref", "other"),
+ ("head", "repo", {"full_name": "other/fork"}),
+ ("base", "repo", {"full_name": "other/target"})]:
+ changed = dict(live, **{section: dict(live[section], **{field: value})})
+ with self.subTest(section=section, field=field), self.assertRaises(policy.StaleInputError):
+ fixture.plan({"pull_request": live}, lambda _: changed)
+ with self.assertRaises(policy.StaleInputError):
+ fixture.plan({"pull_request": live}, lambda _: dict(live, state="closed"))
+ fixture.git("checkout", "--detach", "-q", head)
+ with self.assertRaises(policy.StaleInputError):
+ fixture.plan({"pull_request": live}, fetch)
+
+ def test_queued_merge_uses_actual_target_parent_and_api_outage_keeps_pr_identity(self):
+ live = {"number": 7, "state": "open", "head": {"sha": "head", "ref": "feature",
+ "repo": {"full_name": "alice/server"}}, "base": {"sha": "event-base", "repo": {"full_name": "apache/server"}}}
+ def git(*args):
+ if args[0] == "show":
+ return "current-base head"
+ if args[0] == "diff":
+ return "README.md"
+ return "merge"
+ with patch.dict(os.environ, {"GITHUB_SHA": "merge", "GITHUB_EVENT_NAME": ""}), patch.object(
+ policy, "git", side_effect=git), patch.object(policy, "unsafe_documentation", return_value=False):
+ plan = policy.create_plan("server", {"pull_request": live}, "apache/server", lambda _: live)
+ self.assertEqual("current-base", plan["base"])
+ self.assertEqual([], plan["expected"])
+ def unavailable(_):
+ raise subprocess.CalledProcessError(1, "gh")
+ fallback = policy.create_plan("server", {"pull_request": live}, "apache/server", unavailable)
+ self.assertEqual(policy.MAINLINE, set(fallback["selected"]))
+ self.assertEqual((7, "alice/server", "feature", "current-base", "head"),
+ tuple(fallback[key] for key in ["pr", "source", "branch", "base", "head"]))
+ for malformed in [{"pull_request": None}, {"pull_request": {}},
+ {"pull_request": dict(live, head=dict(live["head"], repo=None))}]:
+ with self.subTest(event=malformed), self.assertRaises(policy.StaleInputError):
+ policy.create_plan("server", malformed, "apache/server", lambda _: self.fail("Invalid identity"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_docker_deployment.py b/.github/scripts/test_docker_deployment.py
new file mode 100644
index 0000000000..7eeca7ff8e
--- /dev/null
+++ b/.github/scripts/test_docker_deployment.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Reject successful service checks performed against an unrelated image."""
+
+import importlib.util
+import json
+import os
+from pathlib import Path
+import unittest
+from unittest.mock import patch
+
+spec = importlib.util.spec_from_file_location("deployment", Path(__file__).with_name("docker-deployment.py"))
+deployment = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(deployment)
+
+
+class ImageIdentityTest(unittest.TestCase):
+ def test_rejects_old_image_even_when_service_started(self):
+ with patch.object(deployment, "command", return_value="sha256:old"):
+ with self.assertRaisesRegex(RuntimeError, "uses sha256:old"):
+ deployment.verify_image("container", "hugegraph/server:ci-1-1", "sha256:pr")
+
+ def test_rejects_missing_image_identity(self):
+ with patch.object(deployment, "command", return_value=""):
+ with self.assertRaises(RuntimeError):
+ deployment.verify_image("container", "hugegraph/server:ci-1-1", "")
+
+ def test_accepts_exact_built_image(self):
+ with patch.object(deployment, "command", return_value="sha256:pr"):
+ deployment.verify_image("container", "hugegraph/server:ci-1-1", "sha256:pr")
+
+ def test_identity_failure_cleans_compose_and_never_checks_http(self):
+ with patch.object(deployment, "command", side_effect=["sha256:pr", "container"]), \
+ patch.object(deployment, "verify_image", side_effect=RuntimeError("wrong image")), \
+ patch.object(deployment.subprocess, "run") as run, \
+ patch.object(deployment, "response") as response:
+ with self.assertRaisesRegex(RuntimeError, "wrong image"):
+ deployment.smoke("ci-1-1", "docker-compose.yml", {"server": "hugegraph/hugegraph"})
+ calls = [call.args[0] for call in run.call_args_list]
+ startup = next(args for args in calls if "up" in args)
+ self.assertEqual(startup[-1], "server")
+ self.assertEqual(startup[startup.index("--pull") + 1], "never")
+ self.assertNotIn("hubble", startup)
+ self.assertTrue(any("logs" in args for args in calls))
+ self.assertEqual(calls[-1][-3:], ["down", "-v", "--remove-orphans"])
+ response.assert_not_called()
+
+ def test_startup_timeout_preserves_failure_and_cleans_compose(self):
+ timeout = deployment.subprocess.TimeoutExpired("up", 660)
+ cleanup = deployment.subprocess.CalledProcessError(1, "down")
+ with patch.object(deployment, "command", return_value="sha256:pr"), \
+ patch.object(deployment.subprocess, "run", side_effect=[timeout, None, None, cleanup]) as run:
+ with self.assertRaises(deployment.subprocess.TimeoutExpired):
+ deployment.smoke("ci-1-1", "docker-compose.yml", {"server": "hugegraph/hugegraph"})
+ self.assertEqual(run.call_args_list[-1].args[0][-3:], ["down", "-v", "--remove-orphans"])
+
+ def test_hstore_starts_only_pr_services_and_cleans_after_checks(self):
+ images = {"pd": "hugegraph/pd", "store": "hugegraph/store", "server": "hugegraph/server"}
+ identities = ["sha256:pd", "sha256:store", "sha256:server"]
+ containers = [value for service in images for value in (service, f"sha256:{service}", "healthy")]
+ with patch.object(deployment, "command", side_effect=identities + containers), \
+ patch.object(deployment.subprocess, "run") as run, \
+ patch.object(deployment, "verify_server") as server, \
+ patch.object(deployment, "verify_storage") as storage, \
+ patch.object(deployment, "verify_graph") as graph:
+ deployment.smoke("ci-1-1", "docker-compose-hstore.yml", images)
+ startup = run.call_args_list[0].args[0]
+ self.assertEqual(startup[-3:], ["pd", "store", "server"])
+ self.assertNotIn("hubble", startup)
+ server.assert_called_once_with()
+ storage.assert_called_once_with()
+ graph.assert_called_once_with("hg_pr_ci_1_1_docker_compose_hstore")
+ self.assertEqual(run.call_args_list[-1].args[0][-3:], ["down", "-v", "--remove-orphans"])
+
+
+class GraphSmokeTest(unittest.TestCase):
+ def test_reuses_graph_write_read_and_gremlin_checks_with_auth(self):
+ with patch.dict(os.environ, {"PATH": "/existing/tools"}), \
+ patch.object(deployment.subprocess, "run") as run:
+ deployment.verify_graph("ci_1_1")
+ invocation = run.call_args
+ self.assertTrue(invocation.args[0][1].endswith("run-server-e2e-smoke-test.sh"))
+ self.assertEqual(invocation.args[0][2:], ["http://localhost:8080", "create", "ci_1_1"])
+ self.assertTrue(invocation.kwargs["check"])
+ self.assertEqual(invocation.kwargs["timeout"], 960)
+ environment = invocation.kwargs["env"]
+ self.assertEqual(environment["PATH"], "/existing/tools")
+ self.assertEqual(environment["HUGEGRAPH_USERNAME"], "admin")
+ self.assertEqual(environment["HUGEGRAPH_PASSWORD"], deployment.ADMIN_PASSWORD)
+
+ def test_graph_failure_propagates_and_cleans_both_topologies(self):
+ for topology, images in (
+ ("docker-compose.yml", {"server": "hugegraph/hugegraph"}),
+ ("docker-compose-hstore.yml", {"pd": "hugegraph/pd", "store": "hugegraph/store",
+ "server": "hugegraph/server"})):
+ identities = [f"sha256:{service}" for service in images]
+ containers = [value for service in images for value in (service, f"sha256:{service}", "healthy")]
+ failure = deployment.subprocess.CalledProcessError(1, "graph smoke")
+ with self.subTest(topology=topology), \
+ patch.object(deployment, "command", side_effect=identities + containers), \
+ patch.object(deployment.subprocess, "run") as run, \
+ patch.object(deployment, "verify_server"), \
+ patch.object(deployment, "verify_storage"), \
+ patch.object(deployment, "verify_graph", side_effect=failure) as graph:
+ with self.assertRaises(deployment.subprocess.CalledProcessError) as caught:
+ deployment.smoke("ci-1-1", topology, images)
+ self.assertIs(caught.exception, failure)
+ graph.assert_called_once()
+ calls = [call.args[0] for call in run.call_args_list]
+ self.assertTrue(any("logs" in args for args in calls))
+ self.assertEqual(calls[-1][-3:], ["down", "-v", "--remove-orphans"])
+
+
+class PayloadTest(unittest.TestCase):
+ VERSIONS = json.dumps({"versions": deployment.expected_versions()})
+ GRAPHS = '{"graphs":["hugegraph"]}'
+
+ def test_accepts_public_versions_and_authenticated_graphs(self):
+ with patch.object(deployment, "response", side_effect=[(401, ""), (200, self.GRAPHS),
+ (200, self.VERSIONS)]) as response:
+ deployment.verify_server()
+ calls = response.call_args_list
+ self.assertIsNone(calls[0].args[1])
+ self.assertEqual(calls[1].args[1], "admin:ci-compose-password")
+ self.assertIsNone(calls[2].args[1])
+
+ def test_rejects_graph_error_body_even_when_http_status_is_200(self):
+ for body in ('{"status":-1,"error":"Unauthorized"}', '{"graphs":"hugegraph"}',
+ '{"graphs":[]}'):
+ with self.subTest(body=body), \
+ patch.object(deployment, "response", side_effect=[(401, ""), (200, body)]):
+ with self.assertRaisesRegex(RuntimeError, "graphs array"):
+ deployment.verify_server()
+
+ def test_rejects_invalid_versions_even_when_http_status_is_200(self):
+ for body in ('{"status":200}', '{"versions":{"version":"v1","core":"1.7.0"}}',
+ '{"versions":{"version":"v1","core":"1","api":"","gremlin":"3"}}'):
+ with self.subTest(body=body), \
+ patch.object(deployment, "response", side_effect=[(401, ""), (200, self.GRAPHS), (200, body)]):
+ with self.assertRaisesRegex(RuntimeError, "versions object"):
+ deployment.verify_server()
+
+ def test_rejects_wrong_release_gremlin_and_protocol_versions(self):
+ for key, wrong in (("core", "1.7.0"), ("gremlin", "3.7.3"), ("gremlin", None),
+ ("api", "1.8.0"), ("version", "v2")):
+ payload = json.loads(self.VERSIONS)
+ payload["versions"][key] = wrong
+ with self.subTest(key=key), self.assertRaisesRegex(RuntimeError, "versions object"):
+ deployment.verify_versions(payload)
+
+ def test_rejects_missing_expected_gremlin_version(self):
+ parse = deployment.ET.parse
+
+ def read_pom(path):
+ if path == Path(deployment.__file__).resolve().parents[2] / "hugegraph-server/pom.xml":
+ return deployment.ET.ElementTree(deployment.ET.Element("project"))
+ return parse(path)
+
+ with patch.object(deployment.ET, "parse", side_effect=read_pom):
+ with self.assertRaisesRegex(RuntimeError, "non-empty expected versions"):
+ deployment.expected_versions()
+
+ def test_rejects_unauthenticated_server_access(self):
+ with patch.object(deployment, "response", return_value=(200, self.GRAPHS)):
+ with self.assertRaisesRegex(RuntimeError, "expected 401"):
+ deployment.verify_server()
+
+ def test_accepts_pd_store_health_readiness_and_authenticated_registration(self):
+ responses = [(200, ""), (200, ""), (200, '{"ready":true}'), (401, ""),
+ (200, '{"status":0,"data":{"stores":[{"address":"store:8500"}]}}')]
+ with patch.object(deployment, "response", side_effect=responses) as response:
+ deployment.verify_storage()
+ self.assertEqual(response.call_args_list[-1].args[1], "hg:ci-compose-pd-secret")
+
+ def test_rejects_pd_false_readiness_or_error_body(self):
+ for body in ('{"ready":false}', '{"ready":"true"}', '{"status":-1}'):
+ with self.subTest(body=body), \
+ patch.object(deployment, "response", side_effect=[(200, ""), (200, ""), (200, body)]):
+ with self.assertRaisesRegex(RuntimeError, "ready=true"):
+ deployment.verify_storage()
+
+ def test_rejects_health_error_body_even_when_http_status_is_200(self):
+ with patch.object(deployment, "response", return_value=(200, '{"error":"Unauthorized"}')):
+ with self.assertRaisesRegex(RuntimeError, "health response"):
+ deployment.verify_storage()
+
+ def test_rejects_unregistered_store_or_pd_error(self):
+ for body in ('{"status":0,"data":{"stores":[]}}',
+ '{"status":-1,"data":{"stores":[{}]}}'):
+ responses = [(200, ""), (200, ""), (200, '{"ready":true}'), (401, ""), (200, body)]
+ with self.subTest(body=body), patch.object(deployment, "response", side_effect=responses):
+ with self.assertRaisesRegex(RuntimeError, "registered stores array"):
+ deployment.verify_storage()
diff --git a/.github/workflows/.java-version b/.github/workflows/.java-version
new file mode 100644
index 0000000000..98d9bcb75a
--- /dev/null
+++ b/.github/workflows/.java-version
@@ -0,0 +1 @@
+17
diff --git a/.github/workflows/auto-pr-review.yml b/.github/workflows/auto-pr-review.yml
index 6a585355f7..df4bfeb690 100644
--- a/.github/workflows/auto-pr-review.yml
+++ b/.github/workflows/auto-pr-review.yml
@@ -28,7 +28,7 @@ jobs:
pull-requests: write
steps:
- name: Add review comment
- uses: peter-evans/create-or-update-comment@v4
+ uses: peter-evans/create-or-update-comment@v5
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
diff --git a/.github/workflows/check-dependencies.yml b/.github/workflows/check-dependencies.yml
index 23efa75ddb..71da915301 100644
--- a/.github/workflows/check-dependencies.yml
+++ b/.github/workflows/check-dependencies.yml
@@ -2,39 +2,26 @@
name: "3rd-party"
on:
- push:
- branches:
- - 'release-*'
- pull_request:
- # Keep source/resources and shared build inputs; skip named documentation files.
- paths:
- - '**/pom.xml'
- - '.mvn/**'
- - '.github/configs/**'
- - 'install-dist/**'
- - '**/*.jar'
- - '.github/workflows/check-dependencies.yml'
- - '!**/README*'
- - '!**/AGENTS.md'
- - '!**/CLAUDE.md'
+ workflow_call:
permissions:
contents: read
jobs:
dependency-check:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
env:
USE_STAGE: 'false' # Whether to include the stage repository.
SCRIPT_DEPENDENCY: install-dist/scripts/dependency
+ PYTHONDONTWRITEBYTECODE: '1' # Keep generated Python caches out of source style checks.
steps:
- name: Checkout source
- uses: actions/checkout@v4
- - name: Set up JDK 11
- uses: actions/setup-java@v3
+ uses: actions/checkout@v7
+ - name: Set up project JDK
+ uses: actions/setup-java@v6
with:
- java-version: '11'
- distribution: 'adopt'
+ java-version-file: .github/workflows/.java-version
+ distribution: 'temurin'
- name: use staged maven repo settings
if: ${{ env.USE_STAGE == 'true' }}
@@ -42,21 +29,34 @@ jobs:
cp $HOME/.m2/settings.xml /tmp/settings.xml
mv -vf .github/configs/settings.xml $HOME/.m2/settings.xml
+ - name: Test shared foundation checks
+ run: python3 -m unittest discover -s install-dist/scripts/shared-foundation -p 'test_*.py'
+
+ - name: Test dependency inventory producer and checker
+ run: python3 -m unittest discover -s install-dist/scripts/dependency -p 'test_*.py'
+
- name: mvn install
run: |
mvn install -Dmaven.test.skip=true -ntp --fail-at-end
+ - name: Check shared foundation boundaries and shipped classes
+ run: |
+ REVISION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ python3 install-dist/scripts/shared-foundation/check_shared_foundation.py --revision "$REVISION"
+
- name: generate current dependencies
run: |
- bash $SCRIPT_DEPENDENCY/regenerate_known_dependencies.sh current-dependencies.txt
+ bash $SCRIPT_DEPENDENCY/regenerate_known_dependencies.sh "$PWD/target/dependency/current-dependencies.txt"
- name: check third dependencies
run: |
- bash $SCRIPT_DEPENDENCY/check_dependencies.sh
+ bash $SCRIPT_DEPENDENCY/check_dependencies.sh \
+ "$SCRIPT_DEPENDENCY/known-dependencies.txt" "$PWD/target/dependency/current-dependencies.txt"
dependency-review:
- runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-24.04
steps:
- name: 'Checkout Repository'
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5
# Refer: https://github.com/actions/dependency-review-action
diff --git a/.github/workflows/cluster-test-ci.yml b/.github/workflows/cluster-test-ci.yml
index c2972cb9f9..85c4ef8881 100644
--- a/.github/workflows/cluster-test-ci.yml
+++ b/.github/workflows/cluster-test-ci.yml
@@ -1,56 +1,32 @@
name: "Cluster Test CI"
on:
- push:
- branches:
- - master
- - 'release-*'
- - 'test-*'
- pull_request:
- # Keep source/resources and shared build inputs; skip named documentation files.
- paths:
- - 'pom.xml'
- - '.mvn/**'
- - 'style/**'
- - '.editorconfig'
- - '.github/configs/**'
- - 'hugegraph-commons/**'
- - 'hugegraph-struct/**'
- - 'hugegraph-pd/**'
- - 'hugegraph-store/**'
- - 'hugegraph-server/**'
- - 'hugegraph-cluster-test/**'
- - 'install-dist/**'
- - '.github/workflows/cluster-test-ci.yml'
- - '!**/README*'
- - '!**/AGENTS.md'
- - '!**/CLAUDE.md'
+ workflow_call:
+
+permissions:
+ contents: read
jobs:
cluster-test:
- runs-on: ubuntu-latest
+ timeout-minutes: 120
+ runs-on: ubuntu-24.04
env:
USE_STAGE: 'false' # Whether to include the stage repository.
steps:
- - name: Install JDK 11
- uses: actions/setup-java@v3
- with:
- java-version: '11'
- distribution: 'zulu'
-
- - name: Cache Maven packages
- uses: actions/cache@v3
- with:
- path: ~/.m2
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
-
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
fetch-depth: 5
+ - name: Install project JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: .github/workflows/.java-version
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
+
- name: use staged maven repo settings
if: ${{ env.USE_STAGE == 'true' }}
run: |
@@ -63,8 +39,33 @@ jobs:
- name: Run simple cluster test
run: |
- mvn test -pl hugegraph-cluster-test/hugegraph-clustertest-test -am -P simple-cluster-test
+ set -o pipefail
+ suite_log="$RUNNER_TEMP/simple-cluster-test.log"
+ timeout 45m mvn test -pl hugegraph-cluster-test/hugegraph-clustertest-test \
+ -am -P simple-cluster-test 2>&1 | tee "$suite_log"
+ hugegraph-server/hugegraph-dist/src/assembly/travis/check-java-runtime-logs.sh "$suite_log"
- name: Run multi cluster test
run: |
- mvn test -pl hugegraph-cluster-test/hugegraph-clustertest-test -am -P multi-cluster-test
+ set -o pipefail
+ suite_log="$RUNNER_TEMP/multi-cluster-test.log"
+ timeout 45m mvn test -pl hugegraph-cluster-test/hugegraph-clustertest-test \
+ -am -P multi-cluster-test 2>&1 | tee "$suite_log"
+ hugegraph-server/hugegraph-dist/src/assembly/travis/check-java-runtime-logs.sh "$suite_log"
+
+ - name: Show cluster diagnostics on failure
+ if: failure()
+ run: |
+ echo "[ci] java processes:"
+ ps -ef | grep -E "HugeGraph|hg-|java" | grep -v grep || true
+ echo "[ci] listening tcp ports:"
+ (ss -ltnp || netstat -ltnp || true) 2>&1
+ find hugegraph-cluster-test -path '*/logs/*' -type f | sort | while read -r log; do
+ echo "--- tail -n 200 $log ---"
+ tail -n 200 "$log" || true
+ done
+ find . -path '*/surefire-reports/*' -type f \
+ \( -name '*.txt' -o -name '*.xml' \) | sort | while read -r report; do
+ echo "--- tail -n 120 $report ---"
+ tail -n 120 "$report" || true
+ done
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index d66dc8cee9..3f9b906933 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -1,51 +1,54 @@
name: "CodeQL"
on:
- push:
- branches: [ master, release-* ]
- pull_request:
- # The branches below must be a subset of the branches above
- # branches: [ master ] # enable in all PRs
- schedule:
- - cron: '33 0 * * 5'
+ workflow_call:
+ inputs:
+ enabled:
+ type: boolean
+ default: true
+
+permissions:
+ contents: read
+ actions: read
+ security-events: write
jobs:
analyze:
+ if: inputs.enabled
env:
USE_STAGE: 'false' # Whether to include the stage repository.
- name: Analyze
- runs-on: ubuntu-latest
+ # Preserve the master scan identity when invoked by a reusable-workflow caller.
+ CODEQL_ACTION_ANALYSIS_KEY: .github/workflows/codeql-analysis.yml:analyze
+ name: Analyze (java)
+ runs-on: ubuntu-24.04
permissions:
actions: read
contents: read
security-events: write
- strategy:
- fail-fast: false
- matrix:
- language: [ 'java' ]
-
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
+ with:
+ persist-credentials: false
- name: Setup Java JDK
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v6
with:
distribution: 'zulu'
- java-version: '11'
+ java-version-file: .github/workflows/.java-version
- name: use staged maven repo settings
if: ${{ env.USE_STAGE == 'true' }}
run: |
- cp $HOME/.m2/settings.xml /tmp/settings.xml
- mv -vf .github/configs/settings.xml $HOME/.m2/settings.xml
+ cp "$HOME/.m2/settings.xml" /tmp/settings.xml
+ mv -vf .github/configs/settings.xml "$HOME/.m2/settings.xml"
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3
+ uses: github/codeql-action/init@v4
with:
- languages: ${{ matrix.language }}
+ languages: java
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
@@ -54,7 +57,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v3
+ uses: github/codeql-action/autobuild@v4
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -68,4 +71,6 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: .github/workflows/codeql-analysis.yml:analyze/language:java
diff --git a/.github/workflows/commons-ci.yml b/.github/workflows/commons-ci.yml
index fd01df1825..8246a8cc1d 100644
--- a/.github/workflows/commons-ci.yml
+++ b/.github/workflows/commons-ci.yml
@@ -1,66 +1,75 @@
name: "HugeGraph-Commons CI"
on:
- workflow_dispatch:
- push:
- branches:
- - master
- - 'release-*'
- - 'test-*'
- pull_request:
+ workflow_call:
+ inputs:
+ java-versions:
+ type: string
+ required: true
+
+permissions:
+ contents: read
jobs:
build-commons:
- runs-on: ubuntu-latest
+ timeout-minutes: 20
+ name: Commons tests (Java ${{ matrix.JAVA_VERSION }})
+ runs-on: ubuntu-24.04
env:
USE_STAGE: 'false' # Whether to include the stage repository.
+ TRAVIS_DIR: hugegraph-server/hugegraph-dist/src/assembly/travis
strategy:
fail-fast: false
matrix:
- JAVA_VERSION: ['11']
+ JAVA_VERSION: ${{ fromJSON(inputs.java-versions) }}
steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 2
+
- name: Install JDK ${{ matrix.JAVA_VERSION }}
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v6
with:
java-version: ${{ matrix.JAVA_VERSION }}
distribution: 'zulu'
-
- - name: Cache Maven packages
- uses: actions/cache@v3
- with:
- path: ~/.m2
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
-
- - name: Checkout
- uses: actions/checkout@v3
- with:
- fetch-depth: 2
+ cache: maven
+ cache-jdk: false
- name: Use staged maven repo settings
if: ${{ env.USE_STAGE == 'true' }}
run: |
- cp $HOME/.m2/settings.xml /tmp/settings.xml
- cp -vf .github/configs/settings.xml $HOME/.m2/settings.xml && cat $HOME/.m2/settings.xml
+ cp "$HOME/.m2/settings.xml" /tmp/settings.xml
+ cp -vf .github/configs/settings.xml "$HOME/.m2/settings.xml" && cat "$HOME/.m2/settings.xml"
- name: Install
run: |
- mvn install -Dmaven.javadoc.skip=true -ntp -Dmaven.test.skip=true
+ mvn install -pl hugegraph-commons/hugegraph-common,hugegraph-commons/hugegraph-rpc -am \
+ -Dmaven.javadoc.skip=true -ntp -Dmaven.test.skip=true
- name: Run common test
run: |
- mvn test -pl hugegraph-commons/hugegraph-common -Dtest=UnitTestSuite -DskipCommonsTests=false
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn test -pl hugegraph-commons/hugegraph-common -Dtest=UnitTestSuite \
+ -DskipCommonsTests=false -Dsurefire.failIfNoSpecifiedTests=true 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
- name: Run rpc test
run: |
- mvn test -pl hugegraph-commons/hugegraph-rpc -Dtest=UnitTestSuite -DskipCommonsTests=false
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn test -pl hugegraph-commons/hugegraph-rpc -Dtest=UnitTestSuite \
+ -DskipCommonsTests=false -Dsurefire.failIfNoSpecifiedTests=true 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: hugegraph-commons/target/jacoco.xml
+ flags: commons
disable_search: true
fail_ci_if_error: false
diff --git a/.github/workflows/docker-build-ci.yml b/.github/workflows/docker-build-ci.yml
index 97e2d37a41..5ed8fd5acc 100644
--- a/.github/workflows/docker-build-ci.yml
+++ b/.github/workflows/docker-build-ci.yml
@@ -18,40 +18,38 @@
name: "Docker Build CI"
on:
- push:
- branches:
- - master
- - 'release-*'
- pull_request:
- # Keep source/resources and shared build inputs; skip named documentation files.
- paths:
- - 'pom.xml'
- - '.mvn/**'
- - 'style/**'
- - '.editorconfig'
- - '.github/configs/**'
- - 'hugegraph-commons/**'
- - 'hugegraph-struct/**'
- - 'hugegraph-pd/**'
- - 'hugegraph-store/**'
- - 'hugegraph-server/**'
- - 'hugegraph-cluster-test/**'
- - 'install-dist/**'
- - 'docker/**'
- - '.dockerignore'
- - '.github/workflows/docker-build-ci.yml'
- - '!**/README*'
- - '!**/AGENTS.md'
- - '!**/CLAUDE.md'
+ workflow_call:
+ inputs:
+ smoke-only:
+ type: boolean
+ default: false
+permissions:
+ contents: read
jobs:
- docker-bake-check:
- runs-on: ubuntu-latest
+ docker-build:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 90
+ env:
+ IMAGE_TAG: ci-${{ github.run_id }}-${{ github.run_attempt }}
+ SOURCE_REVISION: ${{ github.sha }}
steps:
- name: Checkout
- uses: actions/checkout@v4
+ id: checkout
+ uses: actions/checkout@v7
+
+ - name: Install project JDK for deployment contracts
+ id: java
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' && !inputs.smoke-only }}
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: .github/workflows/.java-version
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
- name: Verify shared Maven stages stay identical
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' && !inputs.smoke-only }}
shell: bash
run: |
set -euo pipefail
@@ -86,6 +84,7 @@ jobs:
done
- name: Validate shared image build graph
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' && !inputs.smoke-only }}
run: |
set -euo pipefail
@@ -108,83 +107,60 @@ jobs:
all(. == "docker"))
' /tmp/hugegraph-bake.json
- docker-build:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- dockerfile:
- - hugegraph-pd/Dockerfile
- - hugegraph-store/Dockerfile
- - hugegraph-server/Dockerfile
- - hugegraph-server/Dockerfile-hstore
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Build ${{ matrix.dockerfile }}
+ - name: Verify deployment shell and Compose contracts
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' && steps.java.outcome == 'success' }}
+ timeout-minutes: 25
run: |
- IMAGE_ID=$(docker build -q --build-arg SOURCE_REVISION="$GITHUB_SHA" \
- -f ${{ matrix.dockerfile }} .)
- echo "Built: $IMAGE_ID"
- echo "IMAGE_ID=$IMAGE_ID" >> "$GITHUB_ENV"
- HC=$(docker inspect --format='{{json .Config.Healthcheck}}' "$IMAGE_ID")
- 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
- # actually answer. Only the server images run check_port.
- # TODO(docker-ci): this pins the probe dependency, not the behaviour it
- # protects. A full duplicate-start/stop check needs a booted server with
- # a backend, which belongs with the e2e job rather than the image build.
- - name: Port preflight can answer inside ${{ matrix.dockerfile }}
- if: ${{ startsWith(matrix.dockerfile, 'hugegraph-server/') }}
- run: |
- STATE=$(docker run --rm "$IMAGE_ID" bash -c \
- 'source /hugegraph-server/bin/util.sh && port_listen_state 8080')
- echo "port_listen_state 8080 -> $STATE"
- # Assert a positive answer rather than "not unknown": an empty $STATE
- # would otherwise satisfy the check. The step's default `bash -e`
- # already aborts on a failed `docker run`, so this only states intent.
- [[ "$STATE" == "free" || "$STATE" == "busy" ]] || {
- echo "ERROR: no usable socket-table tool (ss/netstat) in ${{ matrix.dockerfile }}"
- exit 1
- }
+ set -euo pipefail
+ status=0
+ if mvn -B -ntp -pl hugegraph-commons/hugegraph-common -am clean compile dependency:build-classpath \
+ -Dmdep.outputFile=target/config-readback-classpath.txt -Dmaven.javadoc.skip=true -DskipTests; then
+ HG_CONFIG_TEST_CLASSPATH="$PWD/hugegraph-commons/hugegraph-common/target/classes"
+ HG_CONFIG_TEST_CLASSPATH+=":$(cat hugegraph-commons/hugegraph-common/target/config-readback-classpath.txt)"
+ export HG_CONFIG_TEST_CLASSPATH
+ if ! bash hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh; then
+ status=1
+ fi
+ else
+ status=1
+ fi
+ if ! bash hugegraph-server/hugegraph-dist/docker/test/test-docker-entrypoint.sh; then
+ status=1
+ fi
+ if ! bash hugegraph-server/hugegraph-dist/src/assembly/travis/test-check-port.sh \
+ hugegraph-server/hugegraph-dist/src/assembly/static \
+ hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh; then
+ status=1
+ fi
+ if ! bash docker/test-compose.sh render; then
+ status=1
+ fi
+ exit "$status"
- - name: Server image API versions match source
- if: ${{ startsWith(matrix.dockerfile, 'hugegraph-server/') }}
+ - name: Build current images and verify authentication, readiness and graph read/write
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
+ timeout-minutes: 45
+ env:
+ SMOKE_ONLY: ${{ inputs.smoke-only }}
run: |
- CHECK_DIR=$(mktemp -d)
- trap 'rm -rf "$CHECK_DIR"' EXIT
- docker run --rm --entrypoint bash \
- -v "$CHECK_DIR:/check" "$IMAGE_ID" -c \
- 'cp /hugegraph-server/lib/hugegraph-api-*.jar \
- /hugegraph-server/lib/hugegraph-common-*.jar /check/'
-
- API_JAR=$(find "$CHECK_DIR" -name 'hugegraph-api-*.jar' -print -quit)
- COMMON_JAR=$(find "$CHECK_DIR" -name 'hugegraph-common-*.jar' -print -quit)
- EXPECTED_MANIFEST=$(sed -n \
- 's|.*\([^<]*\).*|\1|p' \
- hugegraph-server/hugegraph-api/pom.xml)
- ACTUAL_MANIFEST=$(unzip -p "$API_JAR" META-INF/MANIFEST.MF |
- sed -n 's/^Implementation-Version: *//p' | tr -d '\r')
- EXPECTED_PROPERTY=$(sed -n 's/^ApiVersion=//p' \
- hugegraph-commons/hugegraph-common/src/main/resources/version.properties)
- ACTUAL_PROPERTY=$(unzip -p "$COMMON_JAR" version.properties |
- sed -n 's/^ApiVersion=//p' | tr -d '\r')
-
- [[ "$ACTUAL_MANIFEST" == "$EXPECTED_MANIFEST" ]] || {
- echo "ERROR: API manifest is $ACTUAL_MANIFEST; expected $EXPECTED_MANIFEST"
- exit 1
- }
- [[ "$ACTUAL_PROPERTY" == "$EXPECTED_PROPERTY" ]] || {
- echo "ERROR: API property is $ACTUAL_PROPERTY; expected $EXPECTED_PROPERTY"
- exit 1
- }
+ set -euo pipefail
+ docker buildx create --driver docker-container --use
+ docker buildx bake --file docker/bake.hcl --progress=plain \
+ --set '*.platform=linux/amd64' \
+ pd store server-standalone server-hstore
+
+ # Each runtime check uses successfully built images. Preserve a failed
+ # check while allowing the independent deployment check to collect results.
+ status=0
+ if ! bash .github/scripts/check-docker-images.sh; then
+ status=1
+ fi
+ if [[ "$SMOKE_ONLY" != true ]]; then
+ if ! python3 -m unittest discover -s .github/scripts -p 'test_docker_deployment.py'; then
+ status=1
+ fi
+ fi
+ if ! python3 .github/scripts/docker-deployment.py "$IMAGE_TAG"; then
+ status=1
+ fi
+ exit "$status"
diff --git a/.github/workflows/helm-chart-ci.yml b/.github/workflows/helm-chart-ci.yml
new file mode 100644
index 0000000000..d60495fbf6
--- /dev/null
+++ b/.github/workflows/helm-chart-ci.yml
@@ -0,0 +1,536 @@
+#
+# 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.
+#
+
+name: "Helm Chart CI"
+
+on:
+ workflow_call:
+
+permissions:
+ contents: read
+
+jobs:
+ lint-and-render:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ # azure/setup-helm is not on the ASF-approved actions allowlist, which
+ # fails the workflow at startup; install the pinned release directly.
+ - name: install helm
+ run: |
+ curl -fsSLo /tmp/helm.tgz https://get.helm.sh/helm-v3.16.2-linux-amd64.tar.gz
+ echo "9318379b847e333460d33d291d4c088156299a26cd93d570a7f5d0c36e50b5bb /tmp/helm.tgz" | sha256sum -c -
+ tar -xzf /tmp/helm.tgz -C /tmp
+ sudo install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm
+ helm version
+
+ - name: helm lint
+ run: |
+ helm lint helm/hugegraph
+ helm lint helm/hugegraph -f helm/hugegraph/values-single.yaml
+ helm lint helm/hugegraph -f helm/hugegraph/values-cluster.yaml
+ helm lint helm/hugegraph -f helm/hugegraph/values-strict.yaml
+
+ - name: packaged chart regression
+ run: python3 -m unittest discover -s helm -p 'test_helm_package.py' -v
+
+ - name: helm unittest
+ run: |
+ # v1.0.0 is the newest helm-unittest whose plugin.yaml Helm 3.16.2 can
+ # parse (v1.1.x adds a platformHooks field that 3.16.2 rejects).
+ # Pinned by the tag's commit so a moved tag cannot change what runs.
+ helm plugin install https://github.com/helm-unittest/helm-unittest.git \
+ --version ddd5feaa9465e7a3591792524ce7f441d4c5157e
+ helm unittest helm/hugegraph
+
+ - name: helm template
+ run: |
+ for preset in "" "-f helm/hugegraph/values-single.yaml" "-f helm/hugegraph/values-cluster.yaml" "-f helm/hugegraph/values-strict.yaml"; do
+ # shellcheck disable=SC2086 # $preset intentionally splits into flags
+ helm template ci helm/hugegraph $preset > /dev/null
+ done
+ # Positive coverage for every hubble wrapper branch: pd mode with
+ # auth, direct mode with a custom port, and a TLS-less ingress with
+ # ordinary HTTP configuration, without acknowledgement flags.
+ helm template ci helm/hugegraph \
+ --set hubble.enabled=true \
+ --set server.auth.enabled=true \
+ --set server.auth.admin.existingSecret=ci-auth > /dev/null
+ helm template ci helm/hugegraph \
+ --set hubble.enabled=true \
+ --set hubble.allowWithoutServerAuth=true \
+ --set hubble.mode=direct \
+ --set hubble.port=9090 \
+ --set hubble.persistence.enabled=true \
+ --set hubble.ingress.enabled=true \
+ --set hubble.ingress.allowPlainHttp=true > /dev/null
+ # PD sharding knobs rendered as -D system properties, including
+ # the values-file shape where the count arrives as a numeric string
+ helm template ci helm/hugegraph \
+ --set pd.partition.defaultShardCount=3 \
+ --set pd.partition.storeMaxShardCount=12 > /dev/null
+ helm template ci helm/hugegraph \
+ --set-string pd.partition.defaultShardCount=3 > /dev/null
+ # The derived shard count and the raft whitelist and RPC-timeout flags must land in
+ # the PD JAVA_OPTS verbatim: shard count 3 on the default topology,
+ # 1 on the single-node preset, whitelist disabled in both.
+ helm template ci helm/hugegraph \
+ | grep -qF 'value: "-Dpartition.default-shard-count=3 -Draft.ip-whitelist.enabled=false -Draft.rpc-timeout=3000"'
+ helm template ci helm/hugegraph -f helm/hugegraph/values-single.yaml \
+ | grep -qF 'value: "-Dpartition.default-shard-count=1 -Draft.ip-whitelist.enabled=false -Draft.rpc-timeout=3000"'
+ # The stock distributed install must put every Server replica on
+ # the shared PD graph catalog, even without Hubble. Auth is on by
+ # default, so the chart-managed admin Secret must render too.
+ DEFAULT=$(helm template ci helm/hugegraph)
+ grep -qF "printf 'usePD=true\\n'" <<<"$DEFAULT"
+ grep -qF "printf 'pd.peers=%s\\n' \"\${HG_SERVER_PD_PEERS}\"" \
+ <<<"$DEFAULT"
+ grep -qF 'value: "ci-hugegraph-pd-0.ci-hugegraph-pd.default.svc:8686,ci-hugegraph-pd-1.ci-hugegraph-pd.default.svc:8686,ci-hugegraph-pd-2.ci-hugegraph-pd.default.svc:8686"' \
+ <<<"$DEFAULT"
+ grep -qE '^kind: Secret$' <<<"$DEFAULT"
+ grep -qF 'name: ci-admin' <<<"$DEFAULT"
+ grep -qF 'name: ci-auth-token' <<<"$DEFAULT"
+ # A YAML-coercible Secret key must render quoted, or the API
+ # server stores the key as a boolean and the pods cannot find it.
+ helm template ci helm/hugegraph --set-string server.auth.admin.key=on \
+ | grep -qF '"on":'
+ # The install render must hash the generated credentials it writes:
+ # the rollout checksums equal sha256(name|key|sha256(value)) over
+ # the rendered Secret data, so the first upgrade, which reads the
+ # same values back through lookup, renders the same checksums and
+ # rolls nothing.
+ python3 - <<'PY'
+ import hashlib, base64, subprocess, yaml
+ out = subprocess.check_output(['helm', 'template', 'ci', 'helm/hugegraph'])
+ docs = [d for d in yaml.safe_load_all(out) if d]
+ h = lambda s: hashlib.sha256(s.encode()).hexdigest()
+ sec = {d['metadata']['name']: d['data'] for d in docs if d['kind'] == 'Secret'}
+ val = lambda n, k: base64.b64decode(sec[n][k]).decode()
+ pd = next(d for d in docs if d['kind'] == 'StatefulSet' and d['metadata']['name'] == 'ci-hugegraph-pd')
+ srv = next(d for d in docs if d['kind'] == 'Deployment' and d['metadata']['name'] == 'ci-hugegraph-server')
+ ann = lambda d, k: d['spec']['template']['metadata']['annotations'][k]
+ assert ann(pd, 'checksum/pd-auth') == h('ci-pd-auth|secret-key|' + h(val('ci-pd-auth', 'secret-key')))
+ assert ann(srv, 'checksum/pd-auth') == ann(pd, 'checksum/pd-auth')
+ assert ann(srv, 'checksum/auth') == h('ci-admin|password|ci-auth-token|token_secret|'
+ + h(val('ci-admin', 'password')) + '|' + h(val('ci-auth-token', 'token_secret')))
+ print('install-time checksums match the generated Secret values')
+ services = {d['metadata']['name']: d['spec'] for d in docs if d['kind'] == 'Service'}
+ assert services['ci-hugegraph-server']['type'] == 'NodePort'
+ assert services['ci-hugegraph-hubble']['type'] == 'NodePort'
+ assert services['ci-hugegraph-pd-client']['type'] == 'ClusterIP'
+ for name in ('ci-hugegraph-server', 'ci-hugegraph-hubble'):
+ assert 'nodePort' not in services[name]['ports'][0]
+ sts = [d for d in docs if d['kind'] == 'StatefulSet']
+ assert sum(int(d['spec']['replicas']) * len(d['spec'].get('volumeClaimTemplates', []))
+ for d in sts) + sum(d['kind'] == 'PersistentVolumeClaim' for d in docs) == 7
+ assert not any(d['kind'] == 'NetworkPolicy' for d in docs)
+ assert not any('curlimages/' in str(d) for d in docs)
+ print('easy defaults: three-member groups, seven volumes and direct HTTP NodePorts')
+ PY
+
+ - name: reject invalid values
+ run: |
+ # Each case must fail to render; the schema and helpers are the
+ # contract. `! cmd` alone is NOT enforced under `set -e` (errexit
+ # ignores inverted commands), so every case checks explicitly.
+ must_fail() {
+ if helm template ci helm/hugegraph "$@" >/dev/null 2>&1; then
+ echo "expected render failure for: $*" >&2
+ exit 1
+ fi
+ }
+ # Kubernetes permits successThreshold > 1 only for readiness.
+ for component in pd store server hubble; do
+ for probe in startup liveness; do
+ must_fail --set "${component}.probes.${probe}.successThreshold=2"
+ done
+ helm template ci helm/hugegraph \
+ --set "${component}.probes.readiness.successThreshold=2" > /dev/null
+ done
+ # These deployment inputs must serialize unchanged through the properties reader.
+ must_fail --set-string server.serviceId=café
+ must_fail --set-string server.serviceId=😀
+ must_fail --set-string "$(printf 'server.serviceId=ops\tteam')"
+ # Match the Server API graph-space names, including its 48-character limit.
+ must_fail --set-string server.graphSpace=TeamA
+ must_fail --set-string server.graphSpace=team-a
+ must_fail --set-string server.graphSpace=1team
+ graph_space_max=$(printf 'a%.0s' {1..48})
+ helm template ci helm/hugegraph --set-string "server.graphSpace=${graph_space_max}" > /dev/null
+ must_fail --set-string "server.graphSpace=${graph_space_max}a"
+ # The hook queries an explicit, fully qualified existing graph binding.
+ must_fail --set-string server.testGraphAlias=
+ must_fail --set-string server.testGraphAlias=team_a
+ must_fail --set-string server.testGraphAlias=team_a-demo-name
+ must_fail --set-string "server.testGraphAlias=team_a-demo'"
+ graph_name_max=$(printf 'g%.0s' {1..48})
+ helm template ci helm/hugegraph \
+ --set-string "server.testGraphAlias=${graph_space_max}-${graph_name_max}" > /dev/null
+ must_fail --set-string "server.testGraphAlias=${graph_space_max}a-demo"
+ must_fail --set-string "server.testGraphAlias=team_a-${graph_name_max}g"
+ must_fail --set pd.replicas=100
+ must_fail --set pd.pdb.minAvailable=3
+ must_fail --set server.hpa.enabled=true
+ must_fail \
+ --set server.hpa.enabled=true \
+ --set server.hpa.minReplicas=2 \
+ --set server.resources.requests.cpu=100m \
+ --set server.pdb.enabled=true \
+ --set server.pdb.minAvailable=2
+ must_fail --set server.auth.enabled=true --set server.auth.admin.autoGenerate=false --set server.auth.token.autoGenerate=false
+ # pd.auth with every source disabled fails the schema anyOf; the
+ # unit framework at v1.0.0 cannot match schema aborts, so the case
+ # lives here.
+ must_fail --set pd.auth.autoGenerate=false
+ # Commons Configuration trims a properties value, so a credential
+ # with surrounding whitespace would be stored in the Secret and
+ # applied to the account without it. The schema rejects both ends;
+ # these are schema aborts, so they live here rather than in the
+ # unit suite. --set-string keeps the value a string.
+ must_fail --set-string "server.auth.admin.password=review-secret "
+ must_fail --set-string "$(printf 'server.auth.admin.password=review-secret\t')"
+ must_fail --set-string "pd.auth.value=review-secret "
+ # An inner space in the admin password fails too: the image
+ # entrypoint stores it with a backslash before each space, so the
+ # account would answer 401 to the configured value. The PD secret
+ # is not written by that encoder, so its inner space stays legal.
+ must_fail --set-string "server.auth.admin.password=review secret"
+ helm template ci helm/hugegraph \
+ --set-string "pd.auth.value=review secret" >/dev/null
+ # The Server decodes Basic-auth credentials as ASCII, so a non-ASCII
+ # admin password would be applied and then answer 401 to every
+ # request; the schema refuses it before install, as it already
+ # does for the PD REST secret. Backslashes and tabs go the same way.
+ must_fail --set-string "server.auth.admin.password=密码abc"
+ must_fail --set-string "server.auth.admin.password=café"
+ # --set unescapes a single backslash, so the literal needs two.
+ must_fail --set-string 'server.auth.admin.password=review\\secret'
+ must_fail --set-string "$(printf 'server.auth.admin.password=review\tsecret')"
+ must_fail --set-string "pd.auth.value=密码abc"
+ # The Server splits the decoded credential on every colon (400).
+ must_fail --set-string 'server.auth.admin.password=review:secret'
+ helm template ci helm/hugegraph \
+ --set-string "server.auth.admin.password=review-secret_42!" >/dev/null
+ # Auth defaults to on, so Hubble alone is valid; refuse Hubble only
+ # when authentication is explicitly disabled.
+ must_fail --set hubble.enabled=true --set server.auth.enabled=false
+ A=(--set hubble.enabled=true --set hubble.allowWithoutServerAuth=true)
+ must_fail "${A[@]}" --set hubble.port=0
+ must_fail "${A[@]}" \
+ --set hubble.persistence.enabled=true \
+ --set hubble.persistence.size=""
+ must_fail "${A[@]}" --set hubble.service.type=ClusterIP --set hubble.service.nodePort=30080
+ # HTTP is supported directly in both modes. Component choices take
+ # precedence over mode defaults and require no acknowledgement.
+ for mode in easy strict; do
+ for component in pd server hubble; do
+ for kind in NodePort LoadBalancer; do
+ helm template ci helm/hugegraph --set "mode=$mode" \
+ --set "${component}.service.type=$kind" > /dev/null
+ done
+ done
+ helm template ci helm/hugegraph --set "mode=$mode" \
+ --set server.ingress.enabled=true \
+ --set hubble.ingress.enabled=true > /dev/null
+ done
+ must_fail "${A[@]}" --set hubble.mode=bogus
+ must_fail "${A[@]}" --set hubble.image.tag=""
+ # When UI is explicitly disabled, stale credentials may remain unused.
+ helm template ci helm/hugegraph --set hubble.enabled=false \
+ --set server.auth.enabled=false \
+ --set server.auth.admin.password=unused-secret \
+ --set server.auth.token.value=unused-token-that-is-at-least-32-bytes > /dev/null
+ # Respect custom API-server NodePort ranges rather than assuming 30000+.
+ helm template ci helm/hugegraph \
+ --set server.service.nodePort=8802 \
+ --set hubble.service.nodePort=8808 > /dev/null
+ must_fail --set server.service.nodePort=65536
+ must_fail --set server.probes.startup.failureThreshold=1
+ # Kubernetes refuses a StatefulSet that carries rollingUpdate
+ # options with updateStrategy OnDelete; the schema rejects the
+ # combination up front (a schema abort, so it lives here).
+ for component in pd store; do
+ must_fail \
+ --set "${component}.updateStrategy.type=OnDelete" \
+ --set "${component}.updateStrategy.rollingUpdate.partition=1"
+ helm template ci helm/hugegraph \
+ --set "${component}.updateStrategy.type=OnDelete" > /dev/null
+ done
+ # maxUnavailable above 1 lets the controller disrupt two members
+ # of a three-member raft group or shard at once, and PDBs do not
+ # constrain controller rollouts; the schema accepts integer 1 only.
+ for component in pd store; do
+ must_fail --set "${component}.updateStrategy.rollingUpdate.maxUnavailable=2"
+ must_fail --set "${component}.updateStrategy.rollingUpdate.maxUnavailable=0"
+ must_fail --set "${component}.updateStrategy.rollingUpdate.maxUnavailable=-1"
+ must_fail --set-string "${component}.updateStrategy.rollingUpdate.maxUnavailable=67%"
+ must_fail --set-string "${component}.updateStrategy.rollingUpdate.maxUnavailable=foo"
+ must_fail --set-string "${component}.updateStrategy.rollingUpdate.maxUnavailable=1"
+ helm template ci helm/hugegraph \
+ --set "${component}.updateStrategy.rollingUpdate.maxUnavailable=1" > /dev/null
+ done
+ # PD PDB must keep the Raft majority: floor(replicas/2)+1
+ must_fail --set pd.replicas=5 --set pd.pdb.minAvailable=2
+ must_fail --set pd.replicas=4 --set pd.pdb.minAvailable=2
+ helm template ci helm/hugegraph \
+ --set pd.replicas=5 --set pd.pdb.minAvailable=3 > /dev/null
+ helm template ci helm/hugegraph \
+ --set pd.replicas=5 --set pd.pdb.minAvailable=4 > /dev/null
+ # PD sharding knobs: empty or a positive integer; an explicit
+ # shard count must be odd and stay within the store count
+ must_fail --set pd.partition.defaultShardCount=0
+ must_fail --set pd.partition.defaultShardCount=-1
+ must_fail --set-string pd.partition.defaultShardCount=abc
+ must_fail --set pd.partition.defaultShardCount=2
+ must_fail --set pd.partition.defaultShardCount=5
+ must_fail --set pd.partition.storeMaxShardCount=0
+ # extraEnv must not override chart-managed variables
+ must_fail --set 'server.extraEnv[0].name=HG_SERVER_INIT_STORE_ENABLED' \
+ --set 'server.extraEnv[0].value=true'
+ must_fail --set 'pd.extraEnv[0].name=HG_PD_RAFT_PEERS_LIST' \
+ --set 'pd.extraEnv[0].value=x'
+ must_fail --set 'store.extraEnv[0].name=HG_STORE_PD_ADDRESS' \
+ --set 'store.extraEnv[0].value=x'
+ # JAVA_OPTIONS is reserved: a preset value makes the start scripts
+ # skip auto heap sizing and drop the chart's JAVA_OPTS entirely
+ must_fail --set 'pd.extraEnv[0].name=JAVA_OPTIONS' \
+ --set 'pd.extraEnv[0].value=-Xmx1g'
+
+ - name: server credential wrapper behaviour
+ shell: bash
+ run: |
+ # An existingSecret is only seen when the container starts, so run
+ # the rendered Server wrapper against Secret values. A valid value
+ # passes the checks and stops at the missing properties file; an
+ # unsupported one stops earlier with its own message.
+ set -euo pipefail
+ helm template ci helm/hugegraph -s templates/server-deployment.yaml \
+ --set server.auth.admin.existingSecret=ci-byo > /tmp/srv.yaml
+ python3 - <<'PY'
+ import yaml
+ d = yaml.safe_load(open('/tmp/srv.yaml'))
+ open('/tmp/srv.sh', 'w').write(d['spec']['template']['spec']['containers'][0]['args'][0])
+ PY
+ mkdir -p /tmp/srv-run
+ run() {
+ (cd /tmp/srv-run && PASSWORD="$1" bash /tmp/srv.sh 2>&1) || true
+ }
+ expect() {
+ local out
+ out=$(run "$1")
+ if ! grep -qF -- "$2" <<<"$out"; then
+ printf 'password %q: expected "%s", got:\n%s\n' "$1" "$2" "$out" >&2
+ exit 1
+ fi
+ }
+ expect 'review-secret_42!' 'missing or unreadable ./conf/rest-server.properties'
+ expect 'ASCII inner space 123!?' 'must not contain spaces'
+ expect ' leading' 'must not contain spaces'
+ expect 'trailing ' 'must not contain spaces'
+ expect 'review:secret' 'must not contain a colon'
+ expect 'review\secret' 'must not contain backslashes'
+ expect "$(printf 'review\tsecret')" 'must be printable ASCII'
+ expect 'café' 'must be printable ASCII'
+
+ - name: helm test query assertions and shell quoting
+ run: |
+ # Execute the rendered query with the same Groovy version as Server.
+ GROOVY_VERSION=$(python3 - <<'PY_VERSION'
+ import xml.etree.ElementTree as ET
+ pom = ET.parse('pom.xml')
+ print(pom.find('{*}properties/{*}groovy.version').text)
+ PY_VERSION
+ )
+ GROOVY_URL="https://repo.maven.apache.org/maven2/org/apache/groovy/groovy/${GROOVY_VERSION}/groovy-${GROOVY_VERSION}.jar"
+ curl -fsSLo /tmp/groovy.jar "${GROOVY_URL}"
+ curl -fsSLo /tmp/groovy.jar.sha1 "${GROOVY_URL}.sha1"
+ printf '%s /tmp/groovy.jar\n' "$(cat /tmp/groovy.jar.sha1)" | sha1sum -c -
+ GROOVY_JAR=/tmp/groovy.jar python3 helm/test_helm_hook.py
+
+ - name: helm test hook behaviour
+ shell: bash
+ run: |
+ # The hook must fail when any one Server Pod cannot answer a
+ # graph-bound Gremlin query, even while /versions and /graphs
+ # answer 200 through the Service: a Server that started during a
+ # PD roll stays in exactly that state for its whole life. Run the
+ # rendered hook script against loopback fixtures.
+ set -euo pipefail
+ sudo sh -c 'echo "127.0.0.1 ci-hugegraph-server ci-hugegraph-server-headless" >> /etc/hosts'
+ helm template ci helm/hugegraph -s templates/tests/test-connection.yaml > /tmp/hook.yaml
+ python3 - <<'PY'
+ import yaml
+ d = yaml.safe_load(open('/tmp/hook.yaml'))
+ open('/tmp/hook.sh', 'w').write(d['spec']['containers'][0]['command'][2])
+ PY
+ grep -q 'DEFAULT-hugegraph' /tmp/hook.sh
+ # getent stub: the headless lookup answers with the fixture IPs.
+ mkdir -p /tmp/stub
+ printf '#!/bin/sh\nfor ip in ${STUB_IPS}; do printf "%%s STREAM host\\n" "$ip"; done\n' > /tmp/stub/getent
+ chmod +x /tmp/stub/getent
+ cat > /tmp/fixture.py <<'PY'
+ import http.server, json, os, sys, threading, time
+ calls = {}
+ class H(http.server.BaseHTTPRequestHandler):
+ def _send(self, code, body):
+ self.send_response(code)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ def do_GET(self):
+ self._send(200, b'{"versions":{}}')
+ def do_POST(self):
+ body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
+ expected_alias = ('team_a-demo' if os.path.exists('/tmp/selected-graph')
+ else 'DEFAULT-hugegraph')
+ ip = self.server.server_address[0]
+ # broken-: every Gremlin call fails. flip-odd- /
+ # flip-even-: the odd or even calls fail, counted from
+ # the first call made while the file exists, so two Pods
+ # on opposite phases are never healthy in the same sweep.
+ odd = os.path.exists('/tmp/flip-odd-' + ip)
+ even = os.path.exists('/tmp/flip-even-' + ip)
+ n = calls[ip] = (calls.get(ip, 0) + 1) if (odd or even) else 0
+ broken = (os.path.exists('/tmp/broken-' + ip)
+ or body.get('aliases', {}).get('graph') != expected_alias
+ or (odd and n % 2 == 1)
+ or (even and n % 2 == 0))
+ if broken:
+ self._send(400, b'{"exception":"Could not rebind [graph]"}')
+ else:
+ self._send(200, b'{"result":{"data":[0]}}')
+ def log_message(self, *a):
+ pass
+ for ip in sys.argv[1:]:
+ s = http.server.HTTPServer((ip, 8080), H)
+ threading.Thread(target=s.serve_forever, daemon=True).start()
+ time.sleep(3600)
+ PY
+ python3 /tmp/fixture.py 127.0.0.1 127.0.0.2 127.0.0.3 &
+ sleep 1
+ run_hook() {
+ PATH="/tmp/stub:${PATH}" PASSWORD=ci-password HG_TEST_DEADLINE_S=8 \
+ STUB_IPS="$1" sh "${2:-/tmp/hook.sh}"
+ }
+ # Healthy: three Pods, all Gremlin-bound.
+ run_hook "127.0.0.1 127.0.0.2 127.0.0.3"
+ # One Pod answers REST but not Gremlin: the hook must fail.
+ touch /tmp/broken-127.0.0.3
+ if run_hook "127.0.0.1 127.0.0.2 127.0.0.3"; then
+ echo "expected the hook to fail on a broken-binding Pod" >&2
+ exit 1
+ fi
+ rm /tmp/broken-127.0.0.3
+ # Fewer Pods than the replica floor: the hook must fail.
+ if run_hook "127.0.0.1"; then
+ echo "expected the hook to fail below the replica floor" >&2
+ exit 1
+ fi
+ # Alternating health: sweep 1 sees A ok, B broken; sweep 2 sees A
+ # broken, B ok; and so on. No single sweep is clean, so the hook
+ # must fail. A hook that remembered earlier successes per Pod would
+ # pass here after the second sweep.
+ touch /tmp/flip-even-127.0.0.1 /tmp/flip-odd-127.0.0.2
+ if run_hook "127.0.0.1 127.0.0.2 127.0.0.3"; then
+ echo "expected the hook to fail when no sweep is completely healthy" >&2
+ exit 1
+ fi
+ rm /tmp/flip-even-127.0.0.1 /tmp/flip-odd-127.0.0.2
+ # Dual-stack: each Pod publishes an IPv4 and an IPv6 address. Two
+ # Pods give four addresses, which must not satisfy a floor of
+ # three; three Pods must pass without the IPv6 addresses being
+ # queried (nothing listens on them, so a query would fail).
+ if run_hook "127.0.0.1 127.0.0.2 ::1 ::2"; then
+ echo "expected the hook to count Pods, not addresses, on dual-stack" >&2
+ exit 1
+ fi
+ run_hook "127.0.0.1 127.0.0.2 127.0.0.3 ::1 ::2 ::3"
+ # A non-default graph space must query the explicitly selected graph.
+ # A missing alias still fails rather than weakening the per-Pod sweep.
+ touch /tmp/selected-graph
+ for alias in team_a-demo team_a-missing; do
+ helm template ci helm/hugegraph -s templates/tests/test-connection.yaml \
+ --set-string server.graphSpace=team_a \
+ --set-string "server.testGraphAlias=${alias}" > "/tmp/hook-${alias}.yaml"
+ done
+ python3 - <<'PY'
+ import yaml
+ for alias in ('team_a-demo', 'team_a-missing'):
+ d = yaml.safe_load(open('/tmp/hook-' + alias + '.yaml'))
+ open('/tmp/hook-' + alias + '.sh', 'w').write(d['spec']['containers'][0]['command'][2])
+ PY
+ run_hook "127.0.0.1 127.0.0.2 127.0.0.3" /tmp/hook-team_a-demo.sh
+ if run_hook "127.0.0.1 127.0.0.2 127.0.0.3" /tmp/hook-team_a-missing.sh; then
+ echo "expected the hook to fail on a missing selected graph" >&2
+ exit 1
+ fi
+ rm /tmp/selected-graph
+ # The auth-off render must carry no credentials.
+ if helm template ci helm/hugegraph -s templates/tests/test-connection.yaml \
+ --set hubble.enabled=false --set server.auth.enabled=false | grep -q 'Authorization'; then
+ echo "the auth-off hook render must not carry credentials" >&2
+ exit 1
+ fi
+
+ - name: kubeconform
+ shell: bash
+ run: |
+ set -o pipefail
+ curl -sSLo /tmp/kc.tar.gz https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz
+ echo "95f14e87aa28c09d5941f11bd024c1d02fdc0303ccaa23f61cef67bc92619d73 /tmp/kc.tar.gz" | sha256sum -c -
+ tar -xzf /tmp/kc.tar.gz -C /tmp
+ for preset in "" "-f helm/hugegraph/values-single.yaml" "-f helm/hugegraph/values-cluster.yaml" "-f helm/hugegraph/values-strict.yaml"; do
+ # shellcheck disable=SC2086 # $preset intentionally splits into flags
+ helm template ci helm/hugegraph $preset | /tmp/kubeconform -strict -summary -kubernetes-version 1.23.0
+ done
+ helm template ci helm/hugegraph \
+ --set hubble.enabled=true \
+ --set hubble.allowWithoutServerAuth=true \
+ --set hubble.mode=direct \
+ --set hubble.port=9090 \
+ --set hubble.persistence.enabled=true \
+ --set hubble.ingress.enabled=true \
+ --set hubble.ingress.allowPlainHttp=true \
+ | /tmp/kubeconform -strict -summary -kubernetes-version 1.23.0
+ helm template ci helm/hugegraph \
+ --set hubble.enabled=true \
+ --set server.auth.enabled=true \
+ --set server.auth.admin.existingSecret=ci-auth \
+ | /tmp/kubeconform -strict -summary -kubernetes-version 1.23.0
+ helm template ci helm/hugegraph \
+ --set pd.partition.defaultShardCount=3 \
+ --set pd.partition.storeMaxShardCount=12 \
+ | /tmp/kubeconform -strict -summary -kubernetes-version 1.23.0
+
+ - name: legacy --reuse-values compatibility
+ run: |
+ # A release created before a field existed must still render. This has
+ # regressed five times, so it is guarded here rather than by review.
+ # `-f` is NOT equivalent: it merges over the new defaults, whereas
+ # --reuse-values discards them, so the fixture must become values.yaml.
+ LEGACY_ROOT=$(mktemp -d)
+ cp -R helm/hugegraph "$LEGACY_ROOT/chart"
+ cp helm/hugegraph/testdata/values-pre-hardening.yaml "$LEGACY_ROOT/chart/values.yaml"
+ helm template legacy "$LEGACY_ROOT/chart" > /dev/null
+ helm template legacy "$LEGACY_ROOT/chart" --is-upgrade > /dev/null
+
+ - name: helm package
+ run: helm package helm/hugegraph -d /tmp/chart
diff --git a/.github/workflows/licence-checker.yml b/.github/workflows/licence-checker.yml
deleted file mode 100644
index a6e6990a64..0000000000
--- a/.github/workflows/licence-checker.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: "License Checker"
-
-on:
- push:
- branches:
- - master
- - 'release-*'
- pull_request:
-
-jobs:
- check-license:
- runs-on: ubuntu-latest
- env:
- USE_STAGE: 'false' # Whether to include the stage repository.
- steps:
- - uses: actions/checkout@v4
-
- - name: Check License Header
- uses: apache/skywalking-eyes@main
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- log: info
- token: ${{ github.token }}
- config: .licenserc.yaml
-
- - name: License Check (RAT)
- run: |
- mvn apache-rat:check -ntp
- find ./ -name rat.txt -print0 | xargs -0 -I file cat file > merged-rat.txt
- grep "Binaries" merged-rat.txt -C 3 && cat merged-rat.txt
diff --git a/.github/workflows/pd-store-ci.yml b/.github/workflows/pd-store-ci.yml
index 79217cfc58..ceb1d1121c 100644
--- a/.github/workflows/pd-store-ci.yml
+++ b/.github/workflows/pd-store-ci.yml
@@ -1,63 +1,117 @@
name: "HugeGraph-PD & Store & Hstore CI"
on:
- push:
- branches:
- - master
- - 'release-*'
- - 'test-*'
- pull_request:
- # Keep source/resources and shared build inputs; skip named documentation files.
- paths:
- - 'pom.xml'
- - '.mvn/**'
- - 'style/**'
- - '.editorconfig'
- - '.github/configs/**'
- - 'hugegraph-commons/**'
- - 'hugegraph-struct/**'
- - 'hugegraph-pd/**'
- - 'hugegraph-store/**'
- - 'hugegraph-server/**'
- - '.github/workflows/pd-store-ci.yml'
- - 'docker/**'
- - 'install-dist/**'
- - '!**/README*'
- - '!**/AGENTS.md'
- - '!**/CLAUDE.md'
-
-# TODO: consider merge to one ci.yml file
+ workflow_call:
+ inputs:
+ run-struct:
+ type: boolean
+ default: true
+ run-pd:
+ type: boolean
+ default: true
+ run-store:
+ type: boolean
+ default: true
+ run-hstore:
+ type: boolean
+ default: true
+permissions:
+ contents: read
+
jobs:
- struct:
- runs-on: ubuntu-latest
+ rocksdb-compatibility:
+ if: inputs.run-pd || inputs.run-store || inputs.run-hstore
+ permissions:
+ contents: read
+ runs-on: ubuntu-24.04
env:
- USE_STAGE: 'false'
+ BEFORE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || github.sha }}
+ AFTER_SHA: ${{ github.event.pull_request.head.sha || github.event.after || github.sha }}
steps:
- - name: Install JDK 11
- uses: actions/setup-java@v3
+ - name: Require exact comparison revisions
+ run: |
+ for revision in "$BEFORE_SHA" "$AFTER_SHA"; do
+ if [[ ! "$revision" =~ ^[0-9a-f]{40}$ || "$revision" =~ ^0+$ ]]; then
+ echo "Error: cannot determine the before/after revision for RocksDB compatibility" >&2
+ exit 1
+ fi
+ done
+
+ - name: Checkout before
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ env.BEFORE_SHA }}
+ path: before
+ persist-credentials: false
+
+ - name: Checkout after
+ uses: actions/checkout@v7
with:
- java-version: '11'
+ repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
+ ref: ${{ env.AFTER_SHA }}
+ path: after
+ persist-credentials: false
+
+ - name: Install project JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: after/.github/workflows/.java-version
distribution: 'zulu'
- - name: Cache Maven packages
- uses: actions/cache@v3
+ - name: Test only changed RocksDB version pairs
+ run: |
+ bash after/hugegraph-server/hugegraph-test/src/test/rocksdb-compatibility/run.sh \
+ "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \
+ "$RUNNER_TEMP/rocksdb-compatibility"
+
+ - name: Preserve comparison and synthetic-data failure evidence
+ if: always()
+ uses: actions/upload-artifact@v7
with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
+ name: rocksdb-compatibility-${{ github.sha }}
+ path: |
+ ${{ runner.temp }}/rocksdb-compatibility/run.log
+ ${{ runner.temp }}/rocksdb-compatibility/models/
+ ${{ runner.temp }}/rocksdb-compatibility/pairs.tsv
+ ${{ runner.temp }}/rocksdb-compatibility/cases/
+ struct:
+ if: inputs.run-struct || inputs.run-store
+ runs-on: ubuntu-24.04
+ env:
+ USE_STAGE: 'false'
+ TRAVIS_DIR: hugegraph-server/hugegraph-dist/src/assembly/travis
+ steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
fetch-depth: 5
+ - name: Install project JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: .github/workflows/.java-version
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
+
+ - name: Verify Store stop failure propagation and PID retention
+ run: bash hugegraph-server/hugegraph-dist/src/assembly/travis/test-stop-hugegraph-store.sh
+
+ - name: Run Java runtime log checker tests
+ run: bash "$TRAVIS_DIR/test-check-java-runtime-logs.sh"
+
- name: Run JaCoCo report validator tests
run: hugegraph-server/hugegraph-dist/src/assembly/travis/test-check-jacoco-report.sh
- name: Run Codecov upload configuration tests
run: hugegraph-server/hugegraph-dist/src/assembly/travis/test-codecov-upload-config.sh
+ - name: Check TinkerPop branch and report gates
+ run: bash hugegraph-server/hugegraph-dist/src/assembly/travis/test-tinkerpop-reports.sh
+
- name: Use staged maven repo settings
+ if: ${{ env.USE_STAGE == 'true' }}
run: |
cp $HOME/.m2/settings.xml /tmp/settings.xml || true
mv -vf .github/configs/settings.xml $HOME/.m2/settings.xml
@@ -79,11 +133,15 @@ jobs:
- name: Run hugegraph-struct test
if: ${{ hashFiles('hugegraph-struct/pom.xml') != '' }}
run: |
- mvn -U -ntp -pl hugegraph-struct test
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn -U -ntp -pl hugegraph-struct test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
pd:
- needs: struct
- runs-on: ubuntu-latest
+ if: inputs.run-pd
+ timeout-minutes: 30
+ runs-on: ubuntu-24.04
env:
# TODO: avoid duplicated env setup in pd & store
USE_STAGE: 'false' # Whether to include the stage repository.
@@ -93,24 +151,19 @@ jobs:
TEST_REPORT_DIR: hugegraph-pd/hg-pd-test/target/surefire-reports
steps:
- - name: Install JDK 11
- uses: actions/setup-java@v3
- with:
- java-version: '11'
- distribution: 'zulu'
-
- - name: Cache Maven packages
- uses: actions/cache@v3
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
-
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
fetch-depth: 5
+ - name: Install project JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: .github/workflows/.java-version
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
+
- name: use staged maven repo settings
if: ${{ env.USE_STAGE == 'true' }}
run: |
@@ -130,13 +183,47 @@ jobs:
# clean package so their JaCoCo data survives until the final report.
- name: Run common test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-pd/hg-pd-test -am \
- -P pd-common-test -Djacoco.sessionId=pd-common-test
+ -P pd-common-test -Djacoco.sessionId=pd-common-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
- name: Run core test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-pd/hg-pd-test -am \
- -P pd-core-test -Djacoco.sessionId=pd-core-test
+ -P pd-core-test -Djacoco.sessionId=pd-core-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+
+ - name: Verify Surefire single-class routing
+ run: |
+ SUREFIRE_DIR=hugegraph-pd/hg-pd-test/target/surefire-reports
+ REPORT="$SUREFIRE_DIR/TEST-org.apache.hugegraph.pd.core.PDConfigTest.xml"
+ rm -f "$REPORT"
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn test -pl hugegraph-pd/hg-pd-test -am \
+ -P pd-core-test -Dtest=PDConfigTest -ntp 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+ test -s "$REPORT" || {
+ echo "::error::PD single-class test produced no report"
+ exit 1
+ }
+ NEGATIVE_LOG=$(mktemp)
+ trap 'rm -f "$NEGATIVE_LOG"' EXIT
+ if mvn test -pl hugegraph-pd/hg-pd-test -am \
+ -P pd-core-test -Dtest=NoSuchJava17ContractTest -ntp \
+ >"$NEGATIVE_LOG" 2>&1; then
+ cat "$NEGATIVE_LOG"
+ echo "::error::Surefire accepted a missing PD test class"
+ exit 1
+ fi
+ cat "$NEGATIVE_LOG"
+ grep -Fq 'No tests matching pattern "NoSuchJava17ContractTest"' \
+ "$NEGATIVE_LOG"
+ grep -Fq '(pd-core-test) @ hg-pd-test' "$NEGATIVE_LOG"
- name: Check startup test prerequisites (PD)
id: pd-preflight
@@ -147,11 +234,16 @@ jobs:
if ! command -v "$tool" >/dev/null 2>&1; then
echo "can_run=false" >> "$GITHUB_OUTPUT"
echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT"
- exit 0
+ exit 1
fi
done
echo "can_run=true" >> "$GITHUB_OUTPUT"
+ - name: Check explicit Topling launcher selection
+ run: |
+ bash $TRAVIS_DIR/test-topling-selection.sh
+ bash hugegraph-store/hg-store-dist/src/assembly/travis/test-store-preloads.sh
+
- name: Run PD docker entrypoint secret override tests
run: |
$TRAVIS_DIR/test-pd-docker-entrypoint.sh
@@ -175,16 +267,24 @@ jobs:
- name: Prepare env and service
run: |
$TRAVIS_DIR/start-pd.sh
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ echo "PD_DIR=hugegraph-pd/apache-hugegraph-pd-$VERSION" >> "$GITHUB_ENV"
- name: Run client test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-pd/hg-pd-test -am \
- -P pd-client-test -Djacoco.sessionId=pd-client-test
+ -P pd-client-test -Djacoco.sessionId=pd-client-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs"
- name: Run rest test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-pd/hg-pd-test -am \
- -P pd-rest-test -Djacoco.sessionId=pd-rest-test
+ -P pd-rest-test -Djacoco.sessionId=pd-rest-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs"
- name: Generate aggregate coverage report
run: |
@@ -212,17 +312,36 @@ jobs:
"$REPORT_FILE" \
hg-pd-grpc hg-pd-common hg-pd-client hg-pd-core hg-pd-service hg-pd-dist
+ - name: Show PD diagnostics on failure
+ if: failure()
+ run: |
+ VERSION=$(grep -E '^VersionInBash=' \
+ hugegraph-commons/hugegraph-common/src/main/resources/version.properties | cut -d'=' -f2-)
+ [[ "$VERSION" =~ ^[0-9A-Za-z._-]+$ ]] || {
+ echo "Invalid VersionInBash: $VERSION"
+ exit 1
+ }
+ PD_DIR=hugegraph-pd/apache-hugegraph-pd-$VERSION
+ bash $TRAVIS_DIR/ci-service-utils.sh dump "$PD_DIR" HugeGraphPD || true
+ find . -path '*/surefire-reports/*' -type f \
+ \( -name '*.txt' -o -name '*.xml' \) | sort | while read -r report; do
+ echo "--- tail -n 120 $report ---"
+ tail -n 120 "$report" || true
+ done
+
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ env.REPORT_FILE }}
+ flags: pd
disable_search: true
fail_ci_if_error: false
store:
- needs: struct
- runs-on: ubuntu-latest
+ if: inputs.run-store
+ timeout-minutes: 45
+ runs-on: ubuntu-24.04
env:
USE_STAGE: 'false' # Whether to include the stage repository.
# TODO: remove outdated env
@@ -231,24 +350,19 @@ jobs:
TEST_REPORT_DIR: hugegraph-store/hg-store-test/target/surefire-reports
steps:
- - name: Install JDK 11
- uses: actions/setup-java@v3
- with:
- java-version: '11'
- distribution: 'zulu'
-
- - name: Cache Maven packages
- uses: actions/cache@v3
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
-
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
fetch-depth: 5
+ - name: Install project JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: .github/workflows/.java-version
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
+
- name: use staged maven repo settings
if: ${{ env.USE_STAGE == 'true' }}
run: |
@@ -260,6 +374,40 @@ jobs:
run: |
mvn clean package -U -Dmaven.javadoc.skip=true -Dmaven.test.skip=true -ntp --fail-at-end
+ - name: Verify Store Arthas local CLI attachment
+ run: |
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ STORE_DIR=hugegraph-store/apache-hugegraph-store-$VERSION
+ bash "$TRAVIS_DIR/test-arthas-cli.sh" "$STORE_DIR"
+
+ - name: Verify Surefire single-class routing
+ run: |
+ SUREFIRE_DIR=hugegraph-store/hg-store-test/target/surefire-reports
+ REPORT="$SUREFIRE_DIR/TEST-org.apache.hugegraph.store.raftcore.ZeroByteStringHelperTest.xml"
+ rm -f "$REPORT"
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn test -pl hugegraph-store/hg-store-test -am \
+ -P store-raftcore-test -Dtest=ZeroByteStringHelperTest -ntp 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+ test -s "$REPORT" || {
+ echo "::error::Store single-class test produced no report"
+ exit 1
+ }
+ NEGATIVE_LOG=$(mktemp)
+ trap 'rm -f "$NEGATIVE_LOG"' EXIT
+ if mvn test -pl hugegraph-store/hg-store-test -am \
+ -P store-raftcore-test -Dtest=NoSuchJava17ContractTest -ntp \
+ >"$NEGATIVE_LOG" 2>&1; then
+ cat "$NEGATIVE_LOG"
+ echo "::error::Surefire accepted a missing Store test class"
+ exit 1
+ fi
+ cat "$NEGATIVE_LOG"
+ grep -Fq 'No tests matching pattern "NoSuchJava17ContractTest"' \
+ "$NEGATIVE_LOG"
+ grep -Fq '(store-raftcore-test) @ hg-store-test' "$NEGATIVE_LOG"
+
- name: Check source formatting
run: |
mvn editorconfig:check -pl hugegraph-store/hg-store-test -am -ntp
@@ -273,14 +421,14 @@ jobs:
if ! command -v "$tool" >/dev/null 2>&1; then
echo "can_run=false" >> "$GITHUB_OUTPUT"
echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT"
- exit 0
+ exit 1
fi
done
LIMIT_N=$(ulimit -n)
if [[ "$LIMIT_N" != "unlimited" ]] && (( LIMIT_N < 1024 )); then
echo "can_run=false" >> "$GITHUB_OUTPUT"
echo "skip_reason=ulimit -n is $LIMIT_N (store requires >= 1024)" >> "$GITHUB_OUTPUT"
- exit 0
+ exit 1
fi
echo "can_run=true" >> "$GITHUB_OUTPUT"
@@ -300,31 +448,67 @@ jobs:
run: |
$TRAVIS_DIR/start-pd.sh
$TRAVIS_DIR/start-store.sh
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ echo "PD_DIR=hugegraph-pd/apache-hugegraph-pd-$VERSION" >> "$GITHUB_ENV"
+ echo "STORE_DIR=hugegraph-store/apache-hugegraph-store-$VERSION" >> "$GITHUB_ENV"
+
+ - name: Run Store ingress and Arthas security tests
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn test -pl hugegraph-store/hg-store-node -am \
+ -Dtest=StoredRowIngressTest,ArthasSecurityTest -Dsurefire.failIfNoSpecifiedTests=false \
+ -Djacoco.sessionId=store-node-test \
+ -Djacoco.destFile="$PWD/hugegraph-store/hg-store-test/target/store-node-test.exec" 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run common test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-store/hg-store-test -am \
- -P store-common-test -Djacoco.sessionId=store-common-test
+ -P store-common-test -Djacoco.sessionId=store-common-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run client test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-store/hg-store-test -am \
- -P store-client-test -Djacoco.sessionId=store-client-test
+ -P store-client-test -Djacoco.sessionId=store-client-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run rocksdb test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-store/hg-store-test -am \
- -P store-rocksdb-test -Djacoco.sessionId=store-rocksdb-test
+ -P store-rocksdb-test -Djacoco.sessionId=store-rocksdb-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run raft-core test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-store/hg-store-test -am \
- -P store-raftcore-test -Djacoco.sessionId=store-raftcore-test
+ -P store-raftcore-test -Djacoco.sessionId=store-raftcore-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run core test
run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn test -pl hugegraph-store/hg-store-test -am \
+ -P store-core-test -Djacoco.sessionId=store-core-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
+
+ - name: Run server lifecycle and shutdown tests
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
mvn test -pl hugegraph-store/hg-store-test -am \
- -P store-core-test -Djacoco.sessionId=store-core-test
+ -P store-server-test -Djacoco.sessionId=store-server-test 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Generate aggregate coverage report
run: |
@@ -344,6 +528,12 @@ jobs:
"$TEST_REPORT_DIR/TEST-org.apache.hugegraph.store.raftcore.RaftSuiteTest.xml" \
--require-test-report \
"$TEST_REPORT_DIR/TEST-org.apache.hugegraph.store.core.CoreSuiteTest.xml" \
+ --require-test-report \
+ "$TEST_REPORT_DIR/TEST-org.apache.hugegraph.store.service.ServerSuiteTest.xml" \
+ --require-test-report \
+ "hugegraph-store/hg-store-node/target/surefire-reports/TEST-org.apache.hugegraph.store.business.StoredRowIngressTest.xml" \
+ --require-test-report \
+ "hugegraph-store/hg-store-node/target/surefire-reports/TEST-org.apache.hugegraph.store.node.controller.ArthasSecurityTest.xml" \
--require-covered-group hg-store-common \
--require-covered-group hg-store-client \
--require-covered-group hg-store-rocksdb \
@@ -353,46 +543,75 @@ jobs:
--require-session store-rocksdb-test \
--require-session store-raftcore-test \
--require-session store-core-test \
+ --require-session store-server-test \
+ --require-session store-node-test \
"$REPORT_FILE" \
hg-store-grpc hg-store-common hg-store-client hg-store-rocksdb hg-store-core
+ - name: Show Store diagnostics on failure
+ if: failure()
+ run: |
+ VERSION=$(grep -E '^VersionInBash=' \
+ hugegraph-commons/hugegraph-common/src/main/resources/version.properties | cut -d'=' -f2-)
+ [[ "$VERSION" =~ ^[0-9A-Za-z._-]+$ ]] || {
+ echo "Invalid VersionInBash: $VERSION"
+ exit 1
+ }
+ PD_DIR=hugegraph-pd/apache-hugegraph-pd-$VERSION
+ STORE_DIR=hugegraph-store/apache-hugegraph-store-$VERSION
+ bash $TRAVIS_DIR/ci-service-utils.sh dump "$PD_DIR" HugeGraphPD || true
+ bash $TRAVIS_DIR/ci-service-utils.sh dump "$STORE_DIR" HugeGraphStore || true
+ find . -path '*/surefire-reports/*' -type f \
+ \( -name '*.txt' -o -name '*.xml' \) | sort | while read -r report; do
+ echo "--- tail -n 120 $report ---"
+ tail -n 120 "$report" || true
+ done
+
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ env.REPORT_FILE }}
+ flags: store
disable_search: true
fail_ci_if_error: false
hstore:
- needs: struct
- runs-on: ubuntu-latest
+ if: inputs.run-hstore
+ runs-on: ubuntu-24.04
env:
USE_STAGE: 'false' # Whether to include the stage repository.
TRAVIS_DIR: hugegraph-server/hugegraph-dist/src/assembly/travis
REPORT_DIR: target/site/jacoco
BACKEND: hstore
- RELEASE_BRANCH: ${{ startsWith(github.ref_name, 'release-') || startsWith(github.ref_name, 'test-') || startsWith(github.base_ref, 'release-') }}
+ # Long TP suites run only on dedicated validation branches.
+ RUN_TINKERPOP_TESTS: >-
+ ${{
+ startsWith(github.ref_name, 'release-') ||
+ startsWith(github.ref_name, 'test-') ||
+ startsWith(github.ref_name, 'tinkerpop-') ||
+ startsWith(github.head_ref, 'release-') ||
+ startsWith(github.head_ref, 'test-') ||
+ startsWith(github.head_ref, 'tinkerpop-') ||
+ startsWith(github.base_ref, 'release-') ||
+ startsWith(github.base_ref, 'test-') ||
+ startsWith(github.base_ref, 'tinkerpop-')
+ }}
steps:
- - name: Install JDK 11
- uses: actions/setup-java@v3
- with:
- java-version: '11'
- distribution: 'zulu'
-
- - name: Cache Maven packages
- uses: actions/cache@v3
- with:
- path: ~/.m2/repository
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
-
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
with:
fetch-depth: 2
+ - name: Install project JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: .github/workflows/.java-version
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
+
- name: use staged maven repo settings
if: ${{ env.USE_STAGE == 'true' }}
run: |
@@ -406,36 +625,301 @@ jobs:
- name: Prepare env and service
run: |
+ VERSION=$(grep -E '^VersionInBash=' \
+ hugegraph-commons/hugegraph-common/src/main/resources/version.properties | cut -d'=' -f2-)
+ [[ "$VERSION" =~ ^[0-9A-Za-z._-]+$ ]] || {
+ echo "Invalid VersionInBash: $VERSION"
+ exit 1
+ }
+ STORE_CONFIG=hugegraph-store/apache-hugegraph-store-$VERSION/conf/application-pd.yml
+ [[ -f "$STORE_CONFIG" ]] || {
+ echo "Store config not found: $STORE_CONFIG"
+ exit 1
+ }
+ [[ $(grep -Ec '^[[:space:]]*min_write_buffer_number_to_merge:' \
+ "$STORE_CONFIG") -eq 1 ]] || {
+ echo "Unable to locate RocksDB config insertion point: $STORE_CONFIG"
+ exit 1
+ }
+ # Bound file-handle caching for this CI topology without changing
+ # the released Store default for deployments with different limits.
+ sed -i \
+ -e '/^[[:space:]]*max_open_files:/d' \
+ -e '/^[[:space:]]*min_write_buffer_number_to_merge:/a\ max_open_files: 4096' \
+ "$STORE_CONFIG"
+ grep -n 'max_open_files' "$STORE_CONFIG"
+
+ SOFT_OPEN_FILES=$(ulimit -Sn)
+ echo "[ci] open-file limit before HStore startup:" \
+ "soft=$SOFT_OPEN_FILES, hard=$(ulimit -Hn)"
+ if [[ "$SOFT_OPEN_FILES" != "unlimited" ]] &&
+ (( SOFT_OPEN_FILES < 65535 )); then
+ ulimit -Sn 65535
+ fi
+ echo "[ci] open-file limit for HStore:" \
+ "soft=$(ulimit -Sn), hard=$(ulimit -Hn)"
$TRAVIS_DIR/install-backend.sh $BACKEND
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ echo "PD_DIR=hugegraph-pd/apache-hugegraph-pd-$VERSION" >> "$GITHUB_ENV"
+ echo "STORE_DIR=hugegraph-store/apache-hugegraph-store-$VERSION" >> "$GITHUB_ENV"
- name: Run unit test
run: |
- $TRAVIS_DIR/run-unit-test.sh $BACKEND
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-unit-test.sh $BACKEND 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run core test
+ # Bound stuck HStore scans so the diagnostic steps below can run
+ # before the GitHub-hosted job reaches its global execution limit.
+ timeout-minutes: 45
env:
HUGEGRAPH_TEST_TASK_LOG_LEVEL: WARN
run: |
- $TRAVIS_DIR/run-core-test.sh $BACKEND
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-core-test.sh $BACKEND 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run api test
run: |
- $TRAVIS_DIR/run-api-test.sh $BACKEND $REPORT_DIR
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-api-test.sh $BACKEND $REPORT_DIR 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
- name: Run raft test
if: ${{ env.BACKEND == 'rocksdb' }}
run: |
- $TRAVIS_DIR/run-api-test-for-raft.sh $BACKEND $REPORT_DIR
-
- - name: Run TinkerPop test
- if: ${{ env.RELEASE_BRANCH == 'true' }}
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-api-test-for-raft.sh $BACKEND $REPORT_DIR 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
+
+ # Include HStore in the upgrade compatibility gate. The dedicated HStore
+ # service setup above makes its structure/process suites exercise the
+ # distributed backend rather than only the memory and RocksDB paths.
+ - name: Run TinkerPop structure test
+ if: ${{ env.RUN_TINKERPOP_TESTS == 'true' }}
+ # The HStore suite needs close to one hour before global cleanup, so
+ # keep enough headroom to finish cleanup and emit Surefire results.
+ timeout-minutes: 120
+ env:
+ HUGEGRAPH_TEST_TASK_LOG_LEVEL: WARN
run: |
- $TRAVIS_DIR/run-tinkerpop-test.sh $BACKEND tinkerpop
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ # Emit lightweight samples during execution: a lost runner cannot
+ # upload the failure-only diagnostics below.
+ python3 - "$TRAVIS_DIR/run-tinkerpop-test.sh" "$BACKEND" structure 2>&1 <<'PYTHON' | tee "$RUNTIME_LOG"
+ import datetime
+ import os
+ import pathlib
+ import subprocess
+ import sys
+
+ with subprocess.Popen(sys.argv[1:]) as test:
+ while test.poll() is None:
+ try:
+ print("[hstore-resources] " + datetime.datetime.now(
+ datetime.timezone.utc).isoformat(), flush=True)
+ subprocess.run(["free", "-m"], check=False)
+ processes = subprocess.run(
+ ["ps", "-eo", "pid,comm,rss,pcpu", "--sort=-rss"],
+ capture_output=True, text=True, check=False)
+ print("\n".join(processes.stdout.splitlines()[:12]), flush=True)
+ for name in ("/proc/pressure/memory", "/sys/fs/cgroup/memory.events"):
+ path = pathlib.Path(name)
+ if path.is_file():
+ try:
+ print(f"{name}: {path.read_text().strip()}", flush=True)
+ except OSError:
+ pass
+ java = subprocess.run(["pgrep", "-x", "java"],
+ capture_output=True, text=True, check=False)
+ for pid in java.stdout.split():
+ try:
+ with os.scandir(f"/proc/{pid}/fd") as entries:
+ count = sum(1 for _ in entries)
+ print(f"java pid={pid} open_fds={count}", flush=True)
+ except OSError:
+ pass
+ except (OSError, UnicodeError) as error:
+ print(f"[hstore-resources] sampling unavailable: {error}", flush=True)
+ try:
+ result = test.wait(timeout=60)
+ sys.exit(result if result >= 0 else 128 - result)
+ except subprocess.TimeoutExpired:
+ pass
+ result = test.returncode
+ sys.exit(result if result >= 0 else 128 - result)
+ PYTHON
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
+
+ - name: Run TinkerPop process standard test
+ if: ${{ env.RUN_TINKERPOP_TESTS == 'true' }}
+ timeout-minutes: 120
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-tinkerpop-test.sh $BACKEND process-standard 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
+
+ - name: Run TinkerPop process feature test
+ if: ${{ env.RUN_TINKERPOP_TESTS == 'true' }}
+ timeout-minutes: 120
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-tinkerpop-test.sh $BACKEND process-feature 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG" "$PD_DIR/logs" "$STORE_DIR/logs"
+
+ - name: Collect HStore diagnostics on failure or timeout
+ if: ${{ failure() || cancelled() }}
+ timeout-minutes: 10
+ run: |
+ set +e
+ DIAGNOSTICS_DIR=hstore-diagnostics
+ mkdir -p "$DIAGNOSTICS_DIR/threads" \
+ "$DIAGNOSTICS_DIR/surefire" \
+ "$DIAGNOSTICS_DIR/services"
+ exec > >(tee -a "$DIAGNOSTICS_DIR/summary.log") 2>&1
+
+ VERSION=$(grep -E '^VersionInBash=' \
+ hugegraph-commons/hugegraph-common/src/main/resources/version.properties | cut -d'=' -f2-)
+ [[ "$VERSION" =~ ^[0-9A-Za-z._-]+$ ]] || {
+ echo "Invalid VersionInBash: $VERSION"
+ exit 0
+ }
+ PD_DIR=hugegraph-pd/apache-hugegraph-pd-$VERSION
+ STORE_DIR=hugegraph-store/apache-hugegraph-store-$VERSION
+ SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION
+
+ echo "::group::HugeGraphStore file descriptors"
+ STORE_PID_FILE="$STORE_DIR/bin/pid"
+ if [[ -f "$STORE_PID_FILE" ]]; then
+ STORE_PID=$(tr -d '[:space:]' < "$STORE_PID_FILE")
+ if [[ "$STORE_PID" =~ ^[0-9]+$ ]] &&
+ [[ -r "/proc/$STORE_PID/limits" ]] &&
+ [[ -d "/proc/$STORE_PID/fd" ]]; then
+ cat "/proc/$STORE_PID/limits" |
+ tee "$DIAGNOSTICS_DIR/services/store-process-limits.txt" || true
+
+ FD_TARGETS="$DIAGNOSTICS_DIR/services/store-fd-targets.txt"
+ find "/proc/$STORE_PID/fd" -mindepth 1 -maxdepth 1 -type l \
+ -printf '%l\n' >"$FD_TARGETS" \
+ 2>"$DIAGNOSTICS_DIR/services/store-fd-errors.txt" || true
+ ls -l "/proc/$STORE_PID/fd" \
+ >"$DIAGNOSTICS_DIR/services/store-fds.txt" 2>&1 || true
+
+ STORE_FD_COUNT=$(wc -l < "$FD_TARGETS" | tr -d '[:space:]')
+ echo "[ci] Store PID $STORE_PID open fd count: $STORE_FD_COUNT" |
+ tee "$DIAGNOSTICS_DIR/services/store-fd-count.txt" || true
+
+ awk '
+ /\.sst/ { types["rocksdb-sst"]++; next }
+ /^socket:/ { types["socket"]++; next }
+ /^pipe:/ { types["pipe"]++; next }
+ /^anon_inode:/ { types["anon-inode"]++; next }
+ /\/storage\// { types["rocksdb-other"]++; next }
+ { types["other"]++ }
+ END {
+ for (type in types) {
+ print types[type], type
+ }
+ }
+ ' "$FD_TARGETS" | sort -nr |
+ tee "$DIAGNOSTICS_DIR/services/store-fd-types.txt" || true
+
+ sort "$FD_TARGETS" | uniq -c | sort -nr \
+ >"$DIAGNOSTICS_DIR/services/store-fd-target-counts.txt" || true
+ echo "[ci] Store top fd targets:"
+ head -n 50 \
+ "$DIAGNOSTICS_DIR/services/store-fd-target-counts.txt" || true
+ else
+ echo "[ci] Store PID is unavailable for fd diagnostics: $STORE_PID"
+ fi
+ else
+ echo "[ci] Store pid file not found: $STORE_PID_FILE"
+ fi
+ echo "::endgroup::"
+
+ echo "::group::Runner process snapshot"
+ ps -eo pid,ppid,stat,etime,%cpu,%mem,args --sort=pid |
+ tee "$DIAGNOSTICS_DIR/processes.txt" || true
+ (ss -ltnp || netstat -ltnp || true) 2>&1 |
+ tee "$DIAGNOSTICS_DIR/listening-ports.txt" || true
+ echo "::endgroup::"
+
+ echo "::group::Java thread dumps"
+ if command -v jcmd >/dev/null 2>&1; then
+ jcmd -l | tee "$DIAGNOSTICS_DIR/jcmd-list.txt" || true
+ while read -r pid _; do
+ [[ "$pid" =~ ^[0-9]+$ ]] || continue
+ kill -0 "$pid" >/dev/null 2>&1 || continue
+ thread_dump="$DIAGNOSTICS_DIR/threads/java-$pid.txt"
+ timeout 30s jcmd "$pid" Thread.print -l >"$thread_dump" 2>&1
+ if [[ $? -ne 0 ]] && command -v jstack >/dev/null 2>&1; then
+ timeout 30s jstack -l "$pid" >"$thread_dump" 2>&1 || true
+ fi
+ echo "[ci] captured Java thread dump: $thread_dump"
+ done < "$DIAGNOSTICS_DIR/jcmd-list.txt"
+ else
+ echo "[ci] jcmd is unavailable"
+ fi
+ echo "::endgroup::"
+
+ bash $TRAVIS_DIR/ci-service-utils.sh dump "$PD_DIR" HugeGraphPD || true
+ bash $TRAVIS_DIR/ci-service-utils.sh dump "$STORE_DIR" HugeGraphStore || true
+ bash $TRAVIS_DIR/ci-service-utils.sh dump \
+ "$SERVER_DIR" HugeGraphServer || true
+
+ for service in \
+ "pd:$PD_DIR/logs" \
+ "store:$STORE_DIR/logs" \
+ "server:$SERVER_DIR/logs"; do
+ service_name="${service%%:*}"
+ log_dir="${service#*:}"
+ if [[ -d "$log_dir" ]]; then
+ mkdir -p "$DIAGNOSTICS_DIR/services/$service_name"
+ # Skip heap dumps (logs/heapdump_*/): they can be gigabytes.
+ tar -C "$log_dir" --exclude='*.hprof*' -cf - . | \
+ tar -C "$DIAGNOSTICS_DIR/services/$service_name" -xf - || true
+ fi
+ done
+
+ echo "::group::Surefire reports"
+ while read -r report; do
+ destination="$DIAGNOSTICS_DIR/surefire/${report#./}"
+ mkdir -p "$(dirname "$destination")"
+ cp "$report" "$destination" || true
+ echo "--- tail -n 200 $report ---"
+ tail -n 200 "$report" || true
+ done < <(
+ find . -path "./$DIAGNOSTICS_DIR" -prune -o \
+ -path '*/surefire-reports/*' -type f \
+ \( -name '*.txt' -o -name '*.xml' -o \
+ -name '*.dump' -o -name '*.dumpstream' \) \
+ -print | sort
+ )
+ echo "::endgroup::"
+ exit 0
+
+ - name: Upload HStore diagnostics
+ if: ${{ failure() || cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: hstore-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
+ path: hstore-diagnostics
+ if-no-files-found: warn
+ retention-days: 14
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ env.REPORT_DIR }}/*.xml
+ flags: hstore
disable_search: true
fail_ci_if_error: false
diff --git a/.github/workflows/rerun-ci.yml b/.github/workflows/rerun-ci.yml
deleted file mode 100644
index 3fdd4c0f56..0000000000
--- a/.github/workflows/rerun-ci.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: "Rerun CI"
-
-on:
- workflow_run:
- workflows:
- - "HugeGraph-Server CI"
- - "HugeGraph-Commons CI"
- - "HugeGraph-PD & Store & Hstore CI"
- - "Cluster Test CI"
- types:
- - completed
-
-permissions: {}
-
-env:
- MAX_RERUNS: '2'
- RETRY_DELAY_SECONDS: '180'
-
-jobs:
- decide-rerun-action:
- if: ${{ github.event.workflow_run.conclusion == 'failure' }}
- runs-on: ubuntu-latest
- outputs:
- action: ${{ steps.decision.outputs.action }}
- steps:
- - name: Decide rerun action
- id: decision
- env:
- WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
- RUN_ID: ${{ github.event.workflow_run.id }}
- RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
- EVENT_NAME: ${{ github.event.workflow_run.event }}
- HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
- run: |
- set -euo pipefail
-
- action="skip"
- reason="unsupported event: $EVENT_NAME"
-
- if [[ "$EVENT_NAME" == "push" || "$EVENT_NAME" == "pull_request" ]]; then
- if (( RUN_ATTEMPT > MAX_RERUNS )); then
- reason="retry limit reached"
- else
- action="rerun"
- reason="within retry limit"
- fi
- fi
-
- {
- echo "action=$action"
- echo "reason=$reason"
- } >> "$GITHUB_OUTPUT"
-
- {
- echo "### Rerun CI decision"
- echo ""
- echo "- Workflow: $WORKFLOW_NAME"
- echo "- Source event: $EVENT_NAME"
- echo "- Head branch: $HEAD_BRANCH"
- echo "- Run ID: $RUN_ID"
- echo "- Current attempt: $RUN_ATTEMPT"
- echo "- Max automatic reruns: $MAX_RERUNS"
- echo "- Delay seconds: $RETRY_DELAY_SECONDS"
- echo "- Action: $action"
- echo "- Reason: $reason"
- } >> "$GITHUB_STEP_SUMMARY"
-
- rerun-failed-jobs:
- needs: decide-rerun-action
- if: needs.decide-rerun-action.outputs.action == 'rerun'
- permissions:
- actions: write
- contents: read
- runs-on: ubuntu-latest
- steps:
- - name: Wait before rerun
- run: |
- sleep "$RETRY_DELAY_SECONDS"
-
- - name: Rerun failed jobs
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPO: ${{ github.repository }}
- run: |
- gh run rerun ${{ github.event.workflow_run.id }} --failed
diff --git a/.github/workflows/riscv64-ci.yml b/.github/workflows/riscv64-ci.yml
index 9f8ca12057..9b8bce79b0 100644
--- a/.github/workflows/riscv64-ci.yml
+++ b/.github/workflows/riscv64-ci.yml
@@ -28,12 +28,16 @@ env:
tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0
RISCV64_BASE_IMAGE: >-
ubuntu@sha256:4edded5722eb644868b7b976033d241d2ab3fff0a170924df69b200a59a2b994
- DRAGONWELL_RISCV64_ARCHIVE: >-
- Alibaba_Dragonwell_Extended_11.0.31.28.11_riscv64_linux.tar.gz
- DRAGONWELL_RISCV64_SHA256: >-
- 7df2d308f0dca7a779d2854e6da19214f99cd77aa6d4982c3bf981a77266a79b
- DRAGONWELL_RISCV64_URL: >-
- https://github.com/dragonwell-project/dragonwell11/releases/download/dragonwell-extended-11.0.31.28_jdk-11.0.31-ga/Alibaba_Dragonwell_Extended_11.0.31.28.11_riscv64_linux.tar.gz
+ TEMURIN_RISCV64_ARCHIVE: >-
+ OpenJDK17U-jdk_riscv64_linux_hotspot_17.0.20_8.tar.gz
+ TEMURIN_RISCV64_SHA256: >-
+ bc36e8044c88df9f4ec2967c5277d1c8fae572378c12c0bac44dcae15d3af2f7
+ TEMURIN_RISCV64_BASE_URL: >-
+ https://github.com/adoptium/temurin17-binaries/releases/download
+ TEMURIN_RISCV64_RELEASE: jdk-17.0.20%2B8
+ EXPECTED_JAVA_MAJOR: '17'
+ EXPECTED_RISCV64_JAVA_VERSION: '17.0.20'
+ EXPECTED_RISCV64_JAVA_VENDOR: Eclipse Adoptium
RISCV64_CONTAINER: >-
hugegraph-riscv64-ci-${{ github.run_id }}-${{ github.run_attempt }}
@@ -44,7 +48,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
with:
persist-credentials: false
@@ -55,24 +59,28 @@ jobs:
--name "$RISCV64_CONTAINER" "$RISCV64_BASE_IMAGE" sleep infinity
test "$(docker exec "$RISCV64_CONTAINER" uname -m)" = riscv64
- - name: Prepare source and Java 11
+ - name: Prepare source and Java 17
run: |
- ARCHIVE_PATH="$RUNNER_TEMP/$DRAGONWELL_RISCV64_ARCHIVE"
+ ARCHIVE_PATH="$RUNNER_TEMP/$TEMURIN_RISCV64_ARCHIVE"
curl --fail --location --retry 3 --show-error \
- "$DRAGONWELL_RISCV64_URL" --output "$ARCHIVE_PATH"
- echo "$DRAGONWELL_RISCV64_SHA256 $ARCHIVE_PATH" | sha256sum -c -
+ "$TEMURIN_RISCV64_BASE_URL/$TEMURIN_RISCV64_RELEASE/$TEMURIN_RISCV64_ARCHIVE" \
+ --output "$ARCHIVE_PATH"
+ echo "$TEMURIN_RISCV64_SHA256 $ARCHIVE_PATH" | sha256sum -c -
- docker exec "$RISCV64_CONTAINER" mkdir -p /workspace /opt/dragonwell
+ docker exec "$RISCV64_CONTAINER" mkdir -p /workspace /opt/temurin
git ls-files -z | tar --null --files-from=- -cf - | \
docker exec -i "$RISCV64_CONTAINER" tar -xf - -C /workspace
docker cp "$ARCHIVE_PATH" \
- "$RISCV64_CONTAINER:/tmp/$DRAGONWELL_RISCV64_ARCHIVE"
+ "$RISCV64_CONTAINER:/tmp/$TEMURIN_RISCV64_ARCHIVE"
- name: Build and smoke test on RISC-V
run: |
docker exec \
- --env DRAGONWELL_RISCV64_ARCHIVE="$DRAGONWELL_RISCV64_ARCHIVE" \
- --env DRAGONWELL_RISCV64_SHA256="$DRAGONWELL_RISCV64_SHA256" \
+ --env TEMURIN_RISCV64_ARCHIVE="$TEMURIN_RISCV64_ARCHIVE" \
+ --env TEMURIN_RISCV64_SHA256="$TEMURIN_RISCV64_SHA256" \
+ --env EXPECTED_JAVA_MAJOR="$EXPECTED_JAVA_MAJOR" \
+ --env EXPECTED_RISCV64_JAVA_VERSION="$EXPECTED_RISCV64_JAVA_VERSION" \
+ --env EXPECTED_RISCV64_JAVA_VENDOR="$EXPECTED_RISCV64_JAVA_VENDOR" \
"$RISCV64_CONTAINER" bash -euo pipefail -c '
test "$(uname -m)" = riscv64
apt-get -q update
@@ -81,11 +89,11 @@ jobs:
lsof maven procps \
protobuf-compiler protobuf-compiler-grpc-java-plugin
- ARCHIVE_PATH="/tmp/$DRAGONWELL_RISCV64_ARCHIVE"
- echo "$DRAGONWELL_RISCV64_SHA256 $ARCHIVE_PATH" | sha256sum -c -
- tar -xzf "$ARCHIVE_PATH" --strip-components=1 -C /opt/dragonwell
+ ARCHIVE_PATH="/tmp/$TEMURIN_RISCV64_ARCHIVE"
+ echo "$TEMURIN_RISCV64_SHA256 $ARCHIVE_PATH" | sha256sum -c -
+ tar -xzf "$ARCHIVE_PATH" --strip-components=1 -C /opt/temurin
rm "$ARCHIVE_PATH"
- export JAVA_HOME=/opt/dragonwell
+ export JAVA_HOME=/opt/temurin
export PATH="$JAVA_HOME/bin:$PATH"
java -XshowSettings:vm -version
diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml
index b5f5efc270..f21f1f73a1 100644
--- a/.github/workflows/server-ci.yml
+++ b/.github/workflows/server-ci.yml
@@ -2,262 +2,195 @@ name: "HugeGraph-Server CI"
on:
push:
- branches:
- - master
- - 'release-*'
- - 'test-*'
+ branches: [master, 'release-*', 'test-*', 'tinkerpop-*']
pull_request:
+ schedule:
+ - cron: '0 6 * * 1'
+ timezone: Asia/Singapore
+ workflow_dispatch:
+ inputs:
+ test-set:
+ description: Tests to run on the selected branch
+ type: choice
+ default: mainline
+ options: [mainline, distributed, macos-arm, hbase, riscv64, codeql, full]
+
+# A new PR head replaces its old run; scheduled and manual runs stay independent.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.run_attempt == 1 && 'head' || format('rerun-{0}', github.run_id) }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+permissions:
+ contents: read
+ pull-requests: read
jobs:
- wait-storage-shell-test:
- permissions:
- contents: read
- runs-on: ubuntu-24.04
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- persist-credentials: false
-
- - name: Run wait-storage.sh peer failover tests
- run: hugegraph-server/hugegraph-dist/src/assembly/travis/test-wait-storage.sh
-
- build-server:
+ preflight:
+ name: check-license
runs-on: ubuntu-24.04
+ timeout-minutes: 15
env:
- USE_STAGE: 'false' # Whether to include the stage repository.
- TRAVIS_DIR: hugegraph-server/hugegraph-dist/src/assembly/travis
- REPORT_DIR: target/site/jacoco
- BACKEND: ${{ matrix.BACKEND }}
- TRIGGER_BRANCH_NAME: ${{ github.ref_name }}
- HEAD_BRANCH_NAME: ${{ github.head_ref }}
- BASE_BRANCH_NAME: ${{ github.base_ref }}
- TARGET_BRANCH_NAME: ${{ github.base_ref != '' && github.base_ref || github.ref_name }}
- RELEASE_BRANCH: ${{ startsWith(github.ref_name, 'release-') || startsWith(github.ref_name, 'test-') }}
- RAFT_MODE: ${{ startsWith(github.head_ref, 'test') || startsWith(github.head_ref, 'raft') }}
-
- strategy:
- fail-fast: false
- matrix:
- BACKEND: [ memory, rocksdb, hbase ]
- JAVA_VERSION: [ '11' ]
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 5
-
- # TODO: Remove this step after install-backend.sh updated
- - name: Install Java8 for backend
- uses: actions/setup-java@v4
- with:
- java-version: '8'
- distribution: 'zulu'
-
- - name: Prepare backend environment
- run: |
- $TRAVIS_DIR/install-backend.sh $BACKEND && jps -l
-
- - name: Install Java ${{ matrix.JAVA_VERSION }}
- uses: actions/setup-java@v4
- with:
- java-version: ${{ matrix.JAVA_VERSION }}
- distribution: 'zulu'
-
- - name: Cache Maven packages
- uses: actions/cache@v4
- with:
- path: ~/.m2
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
-
- - name: Use staged maven repo settings
- if: ${{ env.USE_STAGE == 'true' }}
- run: |
- cp $HOME/.m2/settings.xml /tmp/settings.xml
- cp -vf .github/configs/settings.xml $HOME/.m2/settings.xml && cat $HOME/.m2/settings.xml
-
- - name: Compile
- run: |
- mvn clean compile -U -Dmaven.javadoc.skip=true -ntp
-
- - name: Validate Docker integration
- if: ${{ env.BACKEND == 'rocksdb' }}
- run: |
- bash hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh
- bash docker/test-compose.sh render
-
- - name: Run Compose auth-on smoke tests
- if: ${{ env.BACKEND == 'rocksdb' }}
- run: bash docker/test-compose.sh smoke
-
- - name: Run check_port unit tests
- if: ${{ env.BACKEND == 'rocksdb' }}
- run: |
- # Validates the ss/netstat port preflight that replaced lsof
- $TRAVIS_DIR/test-check-port.sh \
- hugegraph-server/hugegraph-dist/src/assembly/static \
- hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh
-
- - name: Run Java security properties tests
- if: ${{ env.BACKEND == 'rocksdb' }}
- run: |
- mvn package -Dmaven.test.skip=true -pl hugegraph-server/hugegraph-dist -am -ntp
- VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
- SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION/
- $TRAVIS_DIR/test-java-security-properties.sh $SERVER_DIR
-
- - name: Check startup test prerequisites
- id: server-preflight
- if: ${{ env.BACKEND == 'rocksdb' }}
- run: |
- # Note: lsof removed from prerequisites; check_port now uses ss, falling back
- # to netstat, and warns without blocking when neither can answer.
- # This job always runs on Linux (ubuntu-24.04), which uses fuser for port cleanup.
- for tool in crontab curl java; do
- if ! command -v "$tool" >/dev/null 2>&1; then
- echo "can_run=false" >> "$GITHUB_OUTPUT"
- echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- done
- if ! command -v fuser >/dev/null 2>&1; then
- echo "can_run=false" >> "$GITHUB_OUTPUT"
- echo "skip_reason=missing tool: fuser (required for Linux port cleanup)" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- echo "can_run=true" >> "$GITHUB_OUTPUT"
-
- - name: Run start-hugegraph.sh foreground mode tests
- if: ${{ env.BACKEND == 'rocksdb' && steps.server-preflight.outputs.can_run == 'true' }}
- run: |
- VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
- SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION/
- $TRAVIS_DIR/test-start-hugegraph.sh $SERVER_DIR
-
- - name: Startup tests skipped (missing prerequisites)
- if: ${{ env.BACKEND == 'rocksdb' && steps.server-preflight.outputs.can_run == 'false' }}
- run: |
- echo "::notice::Server startup tests skipped — ${{ steps.server-preflight.outputs.skip_reason }}"
-
- - name: Run unit test
- run: |
- $TRAVIS_DIR/run-unit-test.sh $BACKEND
-
- - name: Run core test
- run: |
- $TRAVIS_DIR/run-core-test.sh $BACKEND
-
- - name: Run api test
- run: |
- if [ "$BACKEND" = "rocksdb" ]; then
- $TRAVIS_DIR/run-api-test.sh $BACKEND $REPORT_DIR true
- else
- $TRAVIS_DIR/run-api-test.sh $BACKEND $REPORT_DIR
- fi
-
- # TODO: disable raft test in normal PR due to the always timeout problem
- - name: Run raft test
- if: ${{ env.RAFT_MODE == 'true' && env.BACKEND == 'rocksdb' }}
- run: |
- $TRAVIS_DIR/run-api-test-for-raft.sh $BACKEND $REPORT_DIR
-
- - name: Run TinkerPop test
- if: ${{ env.RELEASE_BRANCH == 'true' }}
- run: |
- echo "[WARNING] Enter Tinkerpop Test, current 'github.ref_name' is ${{ github.ref_name }}"
- $TRAVIS_DIR/run-tinkerpop-test.sh $BACKEND tinkerpop
-
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
- files: ${{ env.REPORT_DIR }}/*.xml
- disable_search: true
- fail_ci_if_error: false
-
- build-server-macos-rocksdb:
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- include:
- - os: macos-15-intel
- server_java_options: ''
- - os: macos-15
- server_java_options: '-Xms512m -Xmx2g'
- env:
- USE_STAGE: 'false' # Whether to include the stage repository.
- TRAVIS_DIR: hugegraph-server/hugegraph-dist/src/assembly/travis
- REPORT_DIR: target/site/jacoco
- BACKEND: rocksdb
- JAVA_VERSION: '11'
- SERVER_JAVA_OPTIONS: ${{ matrix.server_java_options }}
-
+ PYTHONDONTWRITEBYTECODE: '1'
+ outputs:
+ selection_result: ${{ steps.select.outcome }}
+ java_result: ${{ steps.java.outcome }}
+ java_versions: ${{ steps.java.outputs.versions }}
+ server: ${{ steps.select.outputs.server }}
+ commons: ${{ steps.select.outputs.commons }}
+ struct: ${{ steps.select.outputs.struct }}
+ pd: ${{ steps.select.outputs.pd }}
+ store: ${{ steps.select.outputs.store }}
+ hstore: ${{ steps.select.outputs.hstore }}
+ pd_store: ${{ steps.select.outputs.pd_store }}
+ cluster: ${{ steps.select.outputs.cluster }}
+ docker: ${{ steps.select.outputs.docker }}
+ helm: ${{ steps.select.outputs.helm }}
+ dependency_license: ${{ steps.select.outputs.dependency_license }}
+ macos: ${{ steps.select.outputs.macos }}
+ macos_full: ${{ steps.select.outputs.macos_full }}
+ hbase: ${{ steps.select.outputs.hbase }}
+ riscv64: ${{ steps.select.outputs.riscv64 }}
+ security: ${{ steps.select.outputs.security }}
+ distributed_smoke: ${{ steps.select.outputs.distributed_smoke }}
steps:
- name: Checkout
- uses: actions/checkout@v4
+ id: checkout
+ uses: actions/checkout@v7
with:
- fetch-depth: 5
+ fetch-depth: 0
+ persist-credentials: false
- - name: Install Java ${{ env.JAVA_VERSION }}
- uses: actions/setup-java@v4
+ - name: Select affected tests
+ id: select
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: >-
+ python3 .github/scripts/ci-policy.py plan --project server
+ --event-name "$GITHUB_EVENT_NAME" --event-path "$GITHUB_EVENT_PATH"
+ --repository "$GITHUB_REPOSITORY" --output "$RUNNER_TEMP/ci-plan.json"
+
+ - name: Read project Java runtime
+ id: java
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
+ run: |
+ python3 - <<'PYTHON'
+ import json
+ import os
+ import re
+ from pathlib import Path
+
+ runtime = Path('.github/workflows/.java-version').read_text().strip()
+ if not re.fullmatch(r'[1-9][0-9]*(\.[0-9]+)*', runtime):
+ raise ValueError('Project Java runtime must be a non-empty numeric version')
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
+ print('versions=' + json.dumps([runtime]), file=output)
+ PYTHON
+
+ - name: Record selection
+ if: ${{ !cancelled() && steps.select.outcome == 'success' }}
+ uses: actions/upload-artifact@v4
with:
- java-version: ${{ env.JAVA_VERSION }}
- distribution: 'zulu'
-
- - name: Cache Maven packages
- uses: actions/cache@v4
+ name: ci-plan
+ path: ${{ runner.temp }}/ci-plan.json
+ retention-days: 7
+ overwrite: true
+
+ - name: Test CI policy and deployment checks
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
+ run: python3 -m unittest discover -s .github/scripts -p 'test_*.py'
+
+ - name: Check license headers
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
+ uses: apache/skywalking-eyes@main
with:
- path: ~/.m2
- key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- restore-keys: ${{ runner.os }}-m2
+ log: info
+ token: ${{ github.token }}
+ config: .licenserc.yaml
- - name: Use staged maven repo settings
- if: ${{ env.USE_STAGE == 'true' }}
+ - name: License Check (RAT)
+ if: ${{ !cancelled() && steps.checkout.outcome == 'success' }}
run: |
- cp $HOME/.m2/settings.xml /tmp/settings.xml
- cp -vf .github/configs/settings.xml $HOME/.m2/settings.xml && cat $HOME/.m2/settings.xml
+ mvn apache-rat:check -ntp
+ find . -name rat.txt -print0 | xargs -0 -I file cat file > "$RUNNER_TEMP/merged-rat.txt"
+ cat "$RUNNER_TEMP/merged-rat.txt"
- - name: Compile
- run: |
- mvn clean compile -pl hugegraph-server/hugegraph-test -am -U -Dmaven.javadoc.skip=true -ntp
-
- - name: Run check_port unit tests
- # Validates the ss/netstat port preflight that replaced lsof
- run: |
- $TRAVIS_DIR/test-check-port.sh \
- hugegraph-server/hugegraph-dist/src/assembly/static \
- hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh
-
- - name: Run RocksDB core test
- run: |
- $TRAVIS_DIR/run-core-test.sh $BACKEND
-
- - name: Run RocksDB API test
- run: |
- $TRAVIS_DIR/run-api-test.sh $BACKEND $REPORT_DIR true
-
- - name: Show server log on failure
- if: failure()
- run: |
- VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
- SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION/
- if [ -f "$SERVER_DIR/logs/hugegraph-server.log" ]; then
- tail -n 200 "$SERVER_DIR/logs/hugegraph-server.log"
- fi
-
- - name: Stop RocksDB server
- if: always()
- run: |
- VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
- SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION/
- if [ -f "$SERVER_DIR/bin/pid" ]; then
- $TRAVIS_DIR/stop-server.sh $SERVER_DIR || true
- fi
-
- build-server-riscv64:
- uses: ./.github/workflows/riscv64-ci.yml
+ build-server:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && needs.preflight.outputs.server == 'true' }}
+ uses: ./.github/workflows/server-tests.yml
+ with:
+ backend: memory
+ java-versions: ${{ needs.preflight.outputs.java_versions }}
+ secrets: inherit
+
+ server-rocksdb:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && needs.preflight.outputs.server == 'true' }}
+ uses: ./.github/workflows/server-tests.yml
+ with:
+ backend: rocksdb
+ java-versions: ${{ needs.preflight.outputs.java_versions }}
+ secrets: inherit
+
+ commons:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && needs.preflight.outputs.commons == 'true' }}
+ uses: ./.github/workflows/commons-ci.yml
+ with:
+ java-versions: ${{ needs.preflight.outputs.java_versions }}
+ secrets: inherit
+
+ pd_store:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && needs.preflight.outputs.pd_store == 'true' }}
+ uses: ./.github/workflows/pd-store-ci.yml
+ with:
+ run-struct: ${{ needs.preflight.outputs.struct == 'true' }}
+ run-pd: ${{ needs.preflight.outputs.pd == 'true' }}
+ run-store: ${{ needs.preflight.outputs.store == 'true' }}
+ run-hstore: ${{ needs.preflight.outputs.hstore == 'true' }}
+ secrets: inherit
+
+ cluster:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && needs.preflight.outputs.cluster == 'true' }}
+ uses: ./.github/workflows/cluster-test-ci.yml
+ secrets: inherit
+
+ docker:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && (needs.preflight.outputs.docker == 'true' || needs.preflight.outputs.distributed_smoke == 'true') }}
+ uses: ./.github/workflows/docker-build-ci.yml
+ with:
+ smoke-only: ${{ needs.preflight.outputs.distributed_smoke == 'true' && needs.preflight.outputs.docker != 'true' }}
+
+ helm:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.helm == 'true' }}
+ uses: ./.github/workflows/helm-chart-ci.yml
+
+ dependency_license:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && needs.preflight.outputs.dependency_license == 'true' }}
+ uses: ./.github/workflows/check-dependencies.yml
+
+ compatibility:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && (needs.preflight.outputs.macos == 'true' || needs.preflight.outputs.hbase == 'true' || needs.preflight.outputs.riscv64 == 'true') }}
+ uses: ./.github/workflows/server-compatibility-ci.yml
+ with:
+ java-versions: ${{ needs.preflight.outputs.java_versions }}
+ macos: ${{ needs.preflight.outputs.macos == 'true' }}
+ macos-full: ${{ needs.preflight.outputs.macos_full == 'true' }}
+ hbase: ${{ needs.preflight.outputs.hbase == 'true' }}
+ riscv64: ${{ needs.preflight.outputs.riscv64 == 'true' }}
+ secrets: inherit
+
+ codeql:
+ needs: preflight
+ if: ${{ !cancelled() && needs.preflight.outputs.selection_result == 'success' && needs.preflight.outputs.java_result == 'success' && needs.preflight.outputs.security == 'true' }}
+ uses: ./.github/workflows/codeql-analysis.yml
permissions:
contents: read
+ actions: read
+ security-events: write
diff --git a/.github/workflows/server-compatibility-ci.yml b/.github/workflows/server-compatibility-ci.yml
new file mode 100644
index 0000000000..422182fdd5
--- /dev/null
+++ b/.github/workflows/server-compatibility-ci.yml
@@ -0,0 +1,125 @@
+name: "Server Compatibility CI"
+
+on:
+ workflow_call:
+ inputs:
+ java-versions:
+ type: string
+ required: true
+ hbase:
+ type: boolean
+ default: false
+ riscv64:
+ type: boolean
+ default: false
+ macos:
+ type: boolean
+ default: false
+ macos-full:
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ build-server-backends:
+ if: inputs.hbase
+ uses: ./.github/workflows/server-tests.yml
+ with:
+ backend: hbase
+ java-versions: ${{ inputs.java-versions }}
+ secrets: inherit
+
+ build-server-macos-rocksdb:
+ name: macOS ARM (${{ inputs.macos-full && 'full' || 'smoke' }})
+ if: inputs.macos
+ runs-on: macos-15
+ timeout-minutes: 60
+ env:
+ USE_STAGE: 'false' # Whether to include the stage repository.
+ TRAVIS_DIR: hugegraph-server/hugegraph-dist/src/assembly/travis
+ REPORT_DIR: target/site/jacoco
+ BACKEND: rocksdb
+ EXPECTED_ARCH: arm64
+ EXPECTED_JAVA_MAJOR: ${{ fromJSON(inputs.java-versions)[0] }}
+ SERVER_JAVA_OPTIONS: '-Xms512m -Xmx2g'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 5
+ persist-credentials: false
+
+ - name: Install project JDK
+ uses: actions/setup-java@v6
+ with:
+ java-version-file: .github/workflows/.java-version
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
+
+ - name: Use staged maven repo settings
+ if: ${{ env.USE_STAGE == 'true' }}
+ run: |
+ cp "$HOME/.m2/settings.xml" /tmp/settings.xml
+ cp -vf .github/configs/settings.xml "$HOME/.m2/settings.xml" && cat "$HOME/.m2/settings.xml"
+
+ - name: Prepare bounded runtime commands
+ run: |
+ if ! command -v timeout >/dev/null 2>&1 && ! command -v gtimeout >/dev/null 2>&1; then
+ brew install coreutils
+ fi
+
+ - name: Package RocksDB distribution
+ run: |
+ mvn clean package -pl hugegraph-server/hugegraph-dist -am -U -Drocksdb-only \
+ -Dmaven.test.skip=true -Dmaven.javadoc.skip=true -ntp
+
+ - name: Run check_port unit tests
+ # Validates the ss/netstat port preflight that replaced lsof
+ run: |
+ "$TRAVIS_DIR/test-check-port.sh" \
+ hugegraph-server/hugegraph-dist/src/assembly/static \
+ hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh
+
+ - name: Run native startup, JNI and restart smoke
+ run: |
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ SERVER_DIR="hugegraph-server/apache-hugegraph-server-$VERSION"
+ "$TRAVIS_DIR/run-native-runtime-smoke-test.sh" "$SERVER_DIR"
+
+ - name: Run RocksDB core test
+ if: inputs.macos-full
+ run: |
+ "$TRAVIS_DIR/run-core-test.sh" "$BACKEND"
+
+ - name: Run RocksDB API test
+ if: inputs.macos-full
+ run: |
+ "$TRAVIS_DIR/run-api-test.sh" "$BACKEND" "$REPORT_DIR" true
+
+ - name: Show server log on failure
+ if: failure()
+ run: |
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ SERVER_DIR="hugegraph-server/apache-hugegraph-server-$VERSION"
+ if [ -f "$SERVER_DIR/logs/hugegraph-server.log" ]; then
+ tail -n 200 "$SERVER_DIR/logs/hugegraph-server.log"
+ fi
+
+ - name: Stop RocksDB server
+ if: always()
+ run: |
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ SERVER_DIR="hugegraph-server/apache-hugegraph-server-$VERSION"
+ if [ -f "$SERVER_DIR/bin/pid" ]; then
+ "$TRAVIS_DIR/stop-server.sh" "$SERVER_DIR" || true
+ fi
+
+ build-server-riscv64:
+ if: inputs.riscv64
+ uses: ./.github/workflows/riscv64-ci.yml
+ permissions:
+ contents: read
diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml
new file mode 100644
index 0000000000..1f707604ea
--- /dev/null
+++ b/.github/workflows/server-tests.yml
@@ -0,0 +1,247 @@
+name: Server backend tests
+
+on:
+ workflow_call:
+ inputs:
+ backend:
+ type: string
+ required: true
+ java-versions:
+ type: string
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ build-server:
+ name: Server ${{ inputs.backend }} tests (Java ${{ matrix.JAVA_VERSION }})
+ runs-on: ubuntu-24.04
+ env:
+ USE_STAGE: 'false' # Whether to include the stage repository.
+ TRAVIS_DIR: hugegraph-server/hugegraph-dist/src/assembly/travis
+ REPORT_DIR: target/site/jacoco
+ BACKEND: ${{ inputs.backend }}
+ TRIGGER_BRANCH_NAME: ${{ github.ref_name }}
+ HEAD_BRANCH_NAME: ${{ github.head_ref }}
+ BASE_BRANCH_NAME: ${{ github.base_ref }}
+ TARGET_BRANCH_NAME: ${{ github.base_ref != '' && github.base_ref || github.ref_name }}
+ # Long TP suites run only on dedicated validation branches.
+ RUN_TINKERPOP_TESTS: >-
+ ${{
+ startsWith(github.ref_name, 'release-') ||
+ startsWith(github.ref_name, 'test-') ||
+ startsWith(github.ref_name, 'tinkerpop-') ||
+ startsWith(github.head_ref, 'release-') ||
+ startsWith(github.head_ref, 'test-') ||
+ startsWith(github.head_ref, 'tinkerpop-') ||
+ startsWith(github.base_ref, 'release-') ||
+ startsWith(github.base_ref, 'test-') ||
+ startsWith(github.base_ref, 'tinkerpop-')
+ }}
+ RAFT_MODE: ${{ startsWith(github.ref_name, 'raft-') || startsWith(github.head_ref, 'raft-') }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ JAVA_VERSION: ${{ fromJSON(inputs.java-versions) }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 5
+
+ - name: Prepare HBase container
+ if: inputs.backend == 'hbase'
+ run: $TRAVIS_DIR/install-backend.sh hbase
+
+ - name: Install Java ${{ matrix.JAVA_VERSION }}
+ uses: actions/setup-java@v6
+ with:
+ java-version: ${{ matrix.JAVA_VERSION }}
+ distribution: 'zulu'
+ cache: maven
+ cache-jdk: false
+
+ - name: Use staged maven repo settings
+ if: ${{ env.USE_STAGE == 'true' }}
+ run: |
+ cp $HOME/.m2/settings.xml /tmp/settings.xml
+ cp -vf .github/configs/settings.xml $HOME/.m2/settings.xml && cat $HOME/.m2/settings.xml
+
+ - name: Compile
+ run: |
+ mvn clean compile -pl hugegraph-server/hugegraph-test -am -U -Dmaven.javadoc.skip=true -ntp
+
+ - name: Check startup and native runtime contracts
+ if: inputs.backend == 'rocksdb'
+ run: |
+ "$TRAVIS_DIR/test-wait-storage.sh"
+ "$TRAVIS_DIR/test-start-server.sh"
+ "$TRAVIS_DIR/test-riscv64-java-runtime.sh"
+ bash "$TRAVIS_DIR/test-tinkerpop-reports.sh"
+
+ - name: Validate Docker integration
+ if: ${{ env.BACKEND == 'rocksdb' }}
+ run: |
+ bash hugegraph-server/hugegraph-dist/docker/docker-entrypoint-test.sh
+ bash docker/test-compose.sh render
+
+ - name: Run check_port unit tests
+ if: ${{ env.BACKEND == 'rocksdb' }}
+ run: |
+ # Validates the ss/netstat port preflight that replaced lsof
+ $TRAVIS_DIR/test-check-port.sh \
+ hugegraph-server/hugegraph-dist/src/assembly/static \
+ hugegraph-store/hg-store-dist/src/assembly/static/bin/util.sh
+
+ - name: Run Java security properties tests
+ if: ${{ env.BACKEND == 'rocksdb' }}
+ run: |
+ mvn package -Dmaven.test.skip=true -pl hugegraph-server/hugegraph-dist -am -ntp
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION/
+ $TRAVIS_DIR/test-java-security-properties.sh \
+ "$SERVER_DIR" "$GITHUB_WORKSPACE"
+
+ - name: Check bundled Arthas local CLI
+ if: ${{ env.BACKEND == 'rocksdb' }}
+ run: |
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION
+ bash "$TRAVIS_DIR/test-arthas-cli.sh" "$SERVER_DIR"
+
+ - name: Verify Surefire single-class routing
+ if: ${{ env.BACKEND == 'rocksdb' }}
+ run: |
+ SUREFIRE_DIR=hugegraph-server/hugegraph-test/target/surefire-reports
+ REPORT="$SUREFIRE_DIR/TEST-org.apache.hugegraph.unit.auth.HugeGraphAuthProxyTest.xml"
+ rm -f "$REPORT"
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ mvn test -pl hugegraph-server/hugegraph-test -am \
+ -P unit-test -Dtest=HugeGraphAuthProxyTest -ntp 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+ test -s "$REPORT" || {
+ echo "::error::Server single-class test produced no report"
+ exit 1
+ }
+ NEGATIVE_LOG=$(mktemp)
+ trap 'rm -f "$NEGATIVE_LOG"' EXIT
+ if mvn test -pl hugegraph-server/hugegraph-test -am \
+ -P unit-test -Dtest=NoSuchJava17ContractTest -ntp \
+ >"$NEGATIVE_LOG" 2>&1; then
+ cat "$NEGATIVE_LOG"
+ echo "::error::Surefire accepted a missing Server test class"
+ exit 1
+ fi
+ cat "$NEGATIVE_LOG"
+ grep -Fq 'No tests matching pattern "NoSuchJava17ContractTest"' \
+ "$NEGATIVE_LOG"
+ grep -Fq '(unit-test) @ hugegraph-test' "$NEGATIVE_LOG"
+
+ - name: Check startup test prerequisites
+ id: server-preflight
+ if: ${{ env.BACKEND == 'rocksdb' }}
+ run: |
+ # Note: lsof removed from prerequisites; check_port now uses ss, falling back
+ # to netstat, and warns without blocking when neither can answer.
+ # This job always runs on Linux (ubuntu-24.04), which uses fuser for port cleanup.
+ for tool in crontab curl java; do
+ if ! command -v "$tool" >/dev/null 2>&1; then
+ echo "can_run=false" >> "$GITHUB_OUTPUT"
+ echo "skip_reason=missing tool: $tool" >> "$GITHUB_OUTPUT"
+ exit 1
+ fi
+ done
+ if ! command -v fuser >/dev/null 2>&1; then
+ echo "can_run=false" >> "$GITHUB_OUTPUT"
+ echo "skip_reason=missing tool: fuser (required for Linux port cleanup)" >> "$GITHUB_OUTPUT"
+ exit 1
+ fi
+ echo "can_run=true" >> "$GITHUB_OUTPUT"
+
+ - name: Run start-hugegraph.sh foreground mode tests
+ if: ${{ env.BACKEND == 'rocksdb' && steps.server-preflight.outputs.can_run == 'true' }}
+ run: |
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ SERVER_DIR=hugegraph-server/apache-hugegraph-server-$VERSION/
+ bash $TRAVIS_DIR/test-ci-service-utils.sh "$TRAVIS_DIR/ci-service-utils.sh"
+ bash $TRAVIS_DIR/test-launcher-gc.sh "$GITHUB_WORKSPACE"
+ bash $TRAVIS_DIR/test-cgroup-memory.sh "$SERVER_DIR/bin/util.sh"
+ bash $TRAVIS_DIR/test-start-hugegraph-signal.sh "$SERVER_DIR/bin/start-hugegraph.sh"
+ $TRAVIS_DIR/test-start-hugegraph.sh $SERVER_DIR
+
+ - name: Startup tests skipped (missing prerequisites)
+ if: ${{ env.BACKEND == 'rocksdb' && steps.server-preflight.outputs.can_run == 'false' }}
+ run: |
+ echo "::notice::Server startup tests skipped — ${{ steps.server-preflight.outputs.skip_reason }}"
+
+ - name: Run unit test
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-unit-test.sh $BACKEND 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+
+ - name: Run core test
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-core-test.sh $BACKEND 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+
+ - name: Run api test
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ {
+ if [ "$BACKEND" = "rocksdb" ]; then
+ $TRAVIS_DIR/run-api-test.sh $BACKEND $REPORT_DIR true
+ else
+ $TRAVIS_DIR/run-api-test.sh $BACKEND $REPORT_DIR
+ fi
+ } 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+
+ # TODO: disable raft test in normal PR due to the always timeout problem
+ - name: Run raft test
+ if: ${{ env.RAFT_MODE == 'true' && env.BACKEND == 'rocksdb' }}
+ timeout-minutes: 45
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-api-test-for-raft.sh $BACKEND $REPORT_DIR 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+
+ - name: Run TinkerPop structure test
+ if: ${{ env.RUN_TINKERPOP_TESTS == 'true' && (env.BACKEND == 'memory' || env.BACKEND == 'rocksdb') }}
+ timeout-minutes: 60
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-tinkerpop-test.sh $BACKEND structure 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+
+ - name: Run TinkerPop process test
+ if: ${{ env.RUN_TINKERPOP_TESTS == 'true' && (env.BACKEND == 'memory' || env.BACKEND == 'rocksdb') }}
+ timeout-minutes: 180
+ run: |
+ set -o pipefail
+ RUNTIME_LOG=$(mktemp "$RUNNER_TEMP/java-runtime.XXXXXX")
+ $TRAVIS_DIR/run-tinkerpop-test.sh $BACKEND process 2>&1 | tee "$RUNTIME_LOG"
+ bash "$TRAVIS_DIR/check-java-runtime-logs.sh" "$RUNTIME_LOG"
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v7
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: ${{ env.REPORT_DIR }}/*.xml,hugegraph-server/target/site/jacoco/jacoco.xml
+ flags: server_${{ inputs.backend }}
+ disable_search: true
+ fail_ci_if_error: false
+
+ - name: Stop HBase container
+ if: ${{ always() && inputs.backend == 'hbase' }}
+ run: |
+ "$TRAVIS_DIR/install-hbase.sh" stop
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 4f8484ce69..5c50204aef 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -13,9 +13,11 @@ jobs:
pull-requests: write
steps:
- - uses: actions/stale@v3
+ - uses: actions/stale@v11
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
+ # Preserve the old issue-close classification across the action upgrade.
+ close-issue-reason: completed
stale-issue-message: 'Due to the lack of activity, the current issue is marked as stale and will be closed after 20 days, any update will remove the stale label'
stale-pr-message: 'Due to the lack of activity, the current pr is marked as stale and will be closed after 180 days, any update will remove the stale label'
stale-issue-label: 'inactive'
diff --git a/.serena/memories/code_style_and_conventions.md b/.serena/memories/code_style_and_conventions.md
index 701757a1b6..ce3809cdf4 100644
--- a/.serena/memories/code_style_and_conventions.md
+++ b/.serena/memories/code_style_and_conventions.md
@@ -24,5 +24,5 @@
- Validate: `mvn apache-rat:check -ntp` + `mvn editorconfig:check`
## Build
-- Java 11 target except Commons (Java 8 target); compiler/dependency versions come from module POMs.
+- Java 17 release target, `-Xlint:unchecked`, Lombok 1.18.30 (provided/optional)
- Swagger: `io.swagger.core.v3:swagger-jaxrs2-jakarta` for REST API docs
diff --git a/.serena/memories/ecosystem_and_related_projects.md b/.serena/memories/ecosystem_and_related_projects.md
index 5f405287d9..b5bd9c1eeb 100644
--- a/.serena/memories/ecosystem_and_related_projects.md
+++ b/.serena/memories/ecosystem_and_related_projects.md
@@ -17,7 +17,7 @@ Sources → hugegraph-loader → hugegraph-server → Hubble / Computer / AI
## Integrations
- Big Data: Flink, Spark, HDFS
-- Queries: Gremlin (TinkerPop 3.5.1), OpenCypher, REST API + Swagger UI
+- Queries: Gremlin (TinkerPop 3.8.1), OpenCypher, REST API + Swagger UI
- Storage: RocksDB (default), HStore (distributed)
## Versions
diff --git a/.serena/memories/implementation_patterns_and_guidelines.md b/.serena/memories/implementation_patterns_and_guidelines.md
index 26d19ac26f..ce83957f48 100644
--- a/.serena/memories/implementation_patterns_and_guidelines.md
+++ b/.serena/memories/implementation_patterns_and_guidelines.md
@@ -23,7 +23,7 @@
- After `.proto` changes: `mvn clean compile`; PD/Store gRPC POMs output to each module's `src/main/java/`.
## Query Languages
-- **Gremlin**: Native TinkerPop 3.5.1
+- **Gremlin**: Native TinkerPop 3.8.1
- **OpenCypher**: `hugegraph-api/opencypher/`
- TinkerPop exceptions are passed through in Gremlin responses
@@ -35,7 +35,7 @@
- **Profiles**: `unit-test`, `core-test`, `api-test`, `tinkerpop-structure-test`, `tinkerpop-process-test`
- **Backends in CI**: memory, rocksdb, hbase (matrix)
- **Single test class**: `mvn test -pl hugegraph-server/hugegraph-test -am -P core-test,memory -Dtest=ClassName`
-- TinkerPop tests: only on `release-*`/`test-*` branches
+- TinkerPop tests: `release-*`/`test-*` and upgrade branches selected in `server-ci.yml`
- Server Raft API tests are branch-gated in `server-ci.yml`; Store raft-core tests run in normal `pd-store-ci.yml` CI.
## Docker
@@ -44,6 +44,6 @@
- Container logs: stdout-based
## CI Pipelines
-- `server-ci.yml`: compile + unit/core/API tests (memory/rocksdb/hbase × Java 11)
+- `server-ci.yml`: compile + unit/core/API tests (memory/rocksdb/hbase × Java 17)
- `rerun-ci.yml`: auto-rerun flaky failures (max 2 reruns, 180s delay)
- `auto-pr-review.yml`: auto-comment on new PRs
diff --git a/.serena/memories/key_file_locations.md b/.serena/memories/key_file_locations.md
index ac10c668e8..bde5926a3c 100644
--- a/.serena/memories/key_file_locations.md
+++ b/.serena/memories/key_file_locations.md
@@ -29,7 +29,7 @@
- Dist: `hugegraph-store/hg-store-dist/src/assembly/static/`
## CI Workflows (.github/workflows/)
-- `server-ci.yml` — Server tests (matrix: memory/rocksdb/hbase × Java 11)
+- `server-ci.yml` — Server tests (matrix: memory/rocksdb/hbase × Java 17)
- `pd-store-ci.yml` — PD, Store & HStore tests
- `commons-ci.yml` — Commons tests
- `cluster-test-ci.yml` — Cluster integration
diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md
index a08aea9ec6..deac1e6821 100644
--- a/.serena/memories/project_overview.md
+++ b/.serena/memories/project_overview.md
@@ -14,9 +14,9 @@ Apache HugeGraph is a fast-speed, highly-scalable graph database supporting 10+
- Integration with Flink/Spark/HDFS
## Technology Stack
-- **Language**: Java 11+ (required)
-- **Build**: Maven 3.5+
-- **Graph Framework**: Apache TinkerPop 3.5.1
+- **Language**: Java 17 (required; currently supported release)
+- **Build**: Maven 3.6.3+
+- **Graph Framework**: Apache TinkerPop 3.8.1
- **RPC**: gRPC + Protocol Buffers
- **API Docs**: Swagger (io.swagger.core.v3)
- **Storage**: RocksDB (default/embedded), HStore (distributed/production), HBase (deprecated; planned for removal in 2.0)
diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md
index 22a12b4db7..58a32fb0e4 100644
--- a/.serena/memories/task_completion_checklist.md
+++ b/.serena/memories/task_completion_checklist.md
@@ -22,7 +22,7 @@ mvn clean compile -Dmaven.javadoc.skip=true # Compile warnings
4. Run `./install-dist/scripts/dependency/regenerate_known_dependencies.sh`
## 4. CI Awareness
-- `server-ci.yml`: memory/rocksdb/hbase × Java 11
+- `server-ci.yml`: memory/rocksdb/hbase × Java 17
- `rerun-ci.yml`: auto-retries flaky failures
- `licence-checker.yml`: header validation
- Server Raft API tests are branch-gated; Store raft-core and core tests run in normal PD/Store CI.
diff --git a/AGENTS.md b/AGENTS.md
index a38801710f..e7768ad5eb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,7 +40,7 @@ Commons: shared utilities; independent compiler and test settings
## Build and validation
-Commands run from the repository root. Java 11+ and Maven 3.5+;
+Commands run from the repository root. Java 17 and Maven 3.6.3+;
versions come from [pom.xml](pom.xml), including `${revision}`.
```bash
diff --git a/README.md b/README.md
index 0e48ced340..2237a5e2da 100644
--- a/README.md
+++ b/README.md
@@ -10,13 +10,14 @@
[](https://github.com/apache/hugegraph/actions/workflows/server-ci.yml)
[](https://github.com/apache/hugegraph/actions/workflows/pd-store-ci.yml)
[](https://github.com/apache/hugegraph/releases)
-[](https://deepwiki.com/apache/hugegraph)
+[](https://artifacthub.io/packages/helm/hugegraph-helm/hugegraph-helm)
+[](https://deepwiki.com/apache/hugegraph)
---
-**Quick Navigation:** [Architecture](#architecture) • [Quick Start](#quick-start) • [Module Map](#module-map) • [Ecosystem](#ecosystem) • [For Contributors](#for-contributors) • [Community](#community)
+**Quick Navigation:** [Architecture](#architecture) • [Quick Start](#quick-start) • [Module Map](#module-map) • [Ecosystem](#ecosystem) • [For Contributors](#contributing) • [Community](#community)
---
@@ -66,7 +67,7 @@ HugeGraph supports both **standalone** and **distributed** deployments:
│ HugeGraph Server (:8080) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ REST API │ │ Gremlin │ │ Cypher Engine │ │
- │ │(Jersey 3)│ │ (TP 3.5) │ │ (OpenCypher) │ │
+ │ │(Jersey 3)│ │(TP 3.8.1)│ │ (OpenCypher) │ │
│ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
│ └─────────────┼─────────────────┘ │
│ ┌────────▼────────┐ │
@@ -100,6 +101,8 @@ HugeGraph supports both **standalone** and **distributed** deployments:
See the [backend evolution guide](hugegraph-server/README.md#backend-evolution-and-compatibility) for lifecycle and historical compatibility guidance.
+To use ToplingDB with the RocksDB backend, follow the [three-step switching guide](docs/storage-lifecycle.md#storage-provider-selection-and-toplingdb).
+
### Deployment Mode Comparison
| Mode | Components | Use Case | Data Scale | High Availability |
@@ -115,6 +118,9 @@ See the [backend evolution guide](hugegraph-server/README.md#backend-evolution-a
| [hugegraph-pd](hugegraph-pd/README.md) | Placement Driver for distributed mode - handles meta storage, partition management and cluster scheduling |
| [hugegraph-store](hugegraph-store/README.md) | Distributed storage with Raft consensus for high availability and horizontal scaling |
| [hugegraph-commons](hugegraph-commons) | Shared utilities, RPC framework and common components |
+| [hugegraph-struct](hugegraph-struct/README.md) | Shared schema, IDs, base elements, queries and encoding used by Server and Store |
+
+See the [1.8.0 shared-foundation migration guide](docs/shared-foundation-migration.md) for Java API changes, module ownership and coordinated upgrade requirements.
📊 Click to view detailed architecture diagram (Mermaid)
@@ -125,7 +131,7 @@ flowchart TB
subgraph Server["HugeGraph Server :8080"]
API[REST API Jersey 3]
- GS[Gremlin Server TinkerPop 3.5]
+ GS[Gremlin Server TinkerPop 3.8.1]
CS[Cypher Engine OpenCypher]
CORE[Graph Engine hugegraph-core]
@@ -157,6 +163,8 @@ flowchart TB
## Quick Start
+
+
### 5 Minutes Quick Start
```bash
@@ -176,8 +184,15 @@ curl -X POST http://localhost:8080/gremlin \
### Prerequisites
-- **Java 11+** (required)
-- **Maven 3.5+** (for building from source)
+- **Java 17** (required and currently the only supported Java release)
+- **Maven 3.6.3+** (for building from source)
+
+The launch scripts reject Java versions older than 17. That minimum-version
+check does not qualify later Java releases; use Java 17 unless another release
+is explicitly listed as supported.
+
+Read the [Server runtime guide](docs/server-runtime.md#runtime-and-client-configuration)
+for client configuration and query compatibility checks.
### Option 1: Docker (Fastest)
@@ -207,9 +222,16 @@ For advanced Docker configurations, see:
>
> **Version Tags**: Use release tags (e.g., `1.7.0`) for stable deployments. The `latest` tag should only be used for testing or development.
+### Option 2: Kubernetes with Helm
+
+The HStore Helm chart deploys HugeGraph PD, Store, and Server as a distributed
+Kubernetes cluster. See the [chart documentation](helm/hugegraph/README.md) for
+single-node and highly available presets, configuration, and
+upgrade guidance.
+
-Option 2: Download Binary Package
+Option 3: Download Binary Package
Download pre-built packages from the [Download Page](https://hugegraph.apache.org/docs/download/download/):
@@ -242,7 +264,7 @@ For detailed instructions, see the [Binary Installation Guide](https://hugegraph
-Option 3: Build from Source
+Option 4: Build from Source
Build from source for development or customization:
@@ -281,7 +303,7 @@ curl http://localhost:8080/versions
# "versions": {
# "version": "v1",
# "core": "1.7.0",
-# "gremlin": "3.5.1",
+# "gremlin": "3.8.1",
# "api": "1.7.0"
# }
# }
@@ -296,6 +318,8 @@ gremlin> :> g.V().limit(5)
For comprehensive documentation, visit the [HugeGraph Documentation](https://hugegraph.apache.org/docs/).
+Read the [storage lifecycle and recovery guide](docs/storage-lifecycle.md) before restoring data, mounting store directories or upgrading the storage runtime.
+
## Module Map
@@ -327,7 +351,7 @@ For repository constraints and module guidance, see [AGENTS.md](AGENTS.md).
- Review the [Architecture Diagram](#architecture) above
2. **Set Up Your Environment**
- - Install Java 11+ and Maven 3.5+
+ - Install Java 17 and Maven 3.6.3+
- Follow [BUILDING.md](docs/BUILDING.md) for build instructions
- Configure your IDE to use `.editorconfig` for code style and `style/checkstyle.xml` for Checkstyle rules
diff --git a/docker/README.md b/docker/README.md
index a9d43dcb1f..de89b98605 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,5 +1,7 @@
# HugeGraph Docker Compose
+
+
## Users
### Choose a topology
@@ -48,6 +50,27 @@ A non-empty `HUGEGRAPH_ADMIN_PASSWORD` enables Server authentication, and Hubble
`HUGEGRAPH_ADMIN_PASSWORD` initializes the built-in `admin` account on its first authenticated startup. Changing `.env` does not rotate an existing administrator password; use the HugeGraph user API for credential changes.
+The Server image writes `PASSWORD` to `auth.admin_pa` in the REST configuration.
+It writes `HG_SERVER_AUTH_TOKEN_SECRET` to `auth.token_secret` in both REST and
+graph configuration. An explicit signing key must be at least 32 bytes. Authentication
+string values retain spaces, tabs, backslashes and Unicode through the Server's
+properties reader; keep the same signing key on every replica. Use passwords
+compatible with the existing authentication protocol and the chart's documented
+password constraints. When the signing-key environment variable is omitted,
+the image reuses the configured key instead of rotating it.
+
+Older images could change signing keys containing spaces or Unicode, and a
+trailing space could hide the following configuration property. On upgrade,
+explicitly provide the intended key for those deployments. Tokens signed with
+the previous interpreted key require a new login after the correction.
+
+An older image could also initialize a Unicode administrator password incorrectly
+when the initializer inherited the C locale. Upgrading or setting `PASSWORD`
+again does not repair an existing account's stored password. A working
+administrator must rotate it through the user API. This fix provides no automatic
+recovery when no administrator identity is usable; preserve the existing data and
+arrange account recovery before upgrading.
+
For the verification commands below, load `.env` into your current shell and set the password:
```bash
@@ -69,6 +92,22 @@ To regenerate Hubble configuration after modifying `.env`:
./set-hubble-pd-password.sh hstore # or hstore-ha
```
+### Server heap sizing
+
+The Server launcher sizes its default heap from the smaller of host available memory
+and remaining memory under visible cgroup hard limits. It supports cgroup v1/v2,
+ancestor limits and custom controller mounts. Charged inactive file cache is counted
+as reclaimable when controller statistics are available and consistent. The default heap
+uses half that budget, with a 512 MiB minimum and a 32 GiB maximum; startup fails if the budget is below
+512 MiB. Native allocations, thread stacks and file caches also consume container
+memory, so the heap limit does not bound the process's total memory use.
+
+Explicit heap options still take precedence. For example, use `JAVA_OPTS=-Xmx2g`
+for a fixed Server heap in a Docker container, leaving room for other allocations
+within its memory limit. Replacing `JAVA_OPTS` also replaces the image's default
+options; repeat any defaults you need. JVM container-awareness flags cannot reduce
+an explicit `-Xmx` that exceeds the container's limit.
+
### Standalone
This is the recommended quickstart.
@@ -133,6 +172,22 @@ curl -fsS http://localhost:8088/about
+### PD health and readiness
+
+PD's `/v1/ready` returns HTTP 503 while it cannot serve through a Raft quorum.
+`/v1/health` remains HTTP 200 during an election or temporary quorum loss, but
+returns HTTP 503 after a fatal Raft error or state-machine shutdown. Both health
+responses retain the empty text body. Probes read cached state without waiting
+for the Raft node lock.
+
+A failed snapshot, for example after the PD disk fills, leaves JRaft in a terminal
+error state. Free disk space before restarting the affected PD; freeing space
+alone does not reinitialize its Raft node. Configure liveness against `/v1/health`
+with an image containing this fix and gate traffic on `/v1/ready` using an external
+readiness gate. The shipped Compose `service_healthy` dependencies still use
+`/v1/health`; they do not wait for PD quorum readiness. Older images
+still need the single-PD readiness-based liveness workaround supplied by the Helm chart.
+
### Stop or remove a deployment
`stop` keeps containers and data; `down` removes containers and the network but keeps data. **`down -v` also deletes all topology data.**
@@ -230,6 +285,95 @@ Standalone stores RocksDB data at `/hugegraph-server/rocksdb-data`. The HStore t
`docker compose down` keeps named-volume data. `docker compose down -v` intentionally deletes it.
+### Server service identity
+
+Server image builds containing this configuration mapping accept
+`HG_SERVER_SERVICE_ID` and `HG_SERVER_GRAPHSPACE`, which write `server.service_id` and `server.graphspace`
+in `conf/rest-server.properties`. A property keeps its existing value when both
+its `HG_SERVER_*` variable and its older name are unset or empty; the shipped
+defaults are both `DEFAULT`.
+The older `SERVICE_ID` and `GRAPH_SPACE` variables emitted by the Kubernetes driver
+are also accepted. A non-empty `HG_SERVER_*` value takes precedence over its older name.
+These container identity inputs accept printable ASCII (bytes 0x20–0x7E);
+spaces and backslashes in service IDs round-trip unchanged. Non-ASCII and control
+characters are rejected before the entrypoint writes configuration, because the
+Server reads properties using ISO-8859-1. This is the container input's
+serialization limit; it does not change the core service-name contract.
+The full service ID, including `/` and `-`, is retained when service metadata is
+reloaded or its graph space is cleared.
+`HG_SERVER_GRAPHSPACE` and a migrated `GRAPH_SPACE` must also match the Server
+API graph-space rule: `DEFAULT`, or up to 48 lowercase letters, digits and
+underscores, starting with a letter. Invalid environment values are rejected
+before writing configuration; existing mounted properties are preserved.
+
+For a PD-backed Server, add the variables to the Server container's environment,
+for example `HG_SERVER_SERVICE_ID=analytics` and `HG_SERVER_GRAPHSPACE=team_a`.
+Create `team_a` in the existing cluster before starting the assigned Server;
+these settings do not create graph spaces. The Compose files do not pass these
+variables through from the host, so add explicit `environment:` entries to a
+local Compose override. The Helm chart exposes the same settings as
+[`server.serviceId` and `server.graphSpace`](../helm/hugegraph/README.md#server-service-identity).
+
+Use a chart and Server image from the same source version containing these
+mappings. Older release images without them do not apply these environment
+values; select the matching image build explicitly.
+The Server's existing `SERVICE_ID` normalization (`GraphManager.serviceId()`) is
+unchanged and can map different raw names to the same identifier. Select
+`GRAPHSPACE` and the complete raw `SERVICE_NAME` in discovery queries;
+identifier-only lookup does not provide a uniqueness or isolation guarantee.
+
+This selects the Server's PD service metadata. It does not change
+`server.path_graphspace` for REST paths that omit `/graphspaces/`, or
+configure Hubble's chosen service. Actual non-default service discovery,
+authentication and graph-space isolation still require verification with a
+running Linux cluster.
+
+### Server logs and crash files
+
+The Server container owns its foreground launcher and returns the JVM's exit status after startup,
+including 0 for a normal exit and 137 when the JVM is killed with SIGKILL. TERM and INT request
+shutdown through the launcher using TERM, which shuts down and reaps the JVM; the entrypoint returns
+143 and 130 respectively.
+The startup REST check still uses `HG_SERVER_STARTUP_TIMEOUT_S`, and HStore partition checks still
+run after readiness. Shutdown interrupts a stalled readiness probe or pending partition check and
+reaps its polling processes.
+The partition poll and its final `/v1/partitions` diagnostic each have a separate
+`WAIT_PARTITION_TIMEOUT_S` budget (120 seconds by default). If the JVM exits while the
+final diagnostic is stalled, the entrypoint observes its exit after that request finishes
+or reaches this bound; it does not monitor the JVM immediately during the diagnostic.
+A startup timeout requests termination and returns 1 without waiting for an
+unresponsive JVM beyond the startup deadline; container termination cleans up remaining processes.
+Graceful shutdown requires the JVM to respond to TERM; forceful OOM and orchestrator shutdown testing
+needs a Linux environment.
+
+This section describes images built from current master. Images from 1.7.0 and earlier do not set `STDOUT_MODE`: `docker logs` shows only the entrypoint's output, and the service logs stay in `logs/` inside the container.
+
+Both Server images (`hugegraph/hugegraph` and `hugegraph/server`) write the HugeGraph log to container stdout. WARN and above from Hadoop, ZooKeeper, SOFA, Netty and Commons also reaches stdout. INFO from Hadoop, Netty and Commons, the audit log and the slow-query log stay in files only; ZooKeeper and SOFA log nothing below WARN. Errors that `hugegraph-server.sh` reports before Java starts, such as an unsupported JDK or too little free memory, and fatal bootstrap errors go to stderr and, when `logs/` can be written, also to `hugegraph-server.log`. Earlier entrypoint steps, such as `init-store.sh` in the HStore image, report to stderr only. Either way, when a Server exits during startup, `docker logs` or `kubectl logs --previous` shows why. When the JVM starts, it also prints `Picked up JAVA_TOOL_OPTIONS: ...` with the crash-file defaults below; a start that fails its preflight checks exits before that.
+
+`/hugegraph-server/logs` holds `hugegraph-server.log`, JVM crash logs (`hs_err_pid__.log`) and out-of-memory heap dumps (`heapdump__/java_pid.hprof`), where `` is the container's local time as `YYYYMMDD-HHMMSS`. Each start gets its own `heapdump_*` directory, so the Server and the JVMs it starts, such as computer jobs, write separate dumps. HotSpot names each dump and crash log after the JVM's PID, so a JVM that reuses the PID of an earlier one from the same start cannot write its dump, and its crash log replaces the earlier one's. The host name (on Kubernetes, the pod name, or `spec.hostname` when that is set) and a `-1`, `-2`, ... counter keep names from clashing; the counter also separates pods that share a host name. If the directory cannot be created, for example on a full volume, the Server still starts and dumps go to `logs/` itself as `java_pid.hprof`. That is best effort: a full volume may have no room for the dump either, and a restarted container that reuses a PID cannot write over an earlier dump with the same name. If the logs path contains a double quote or `%`, the launcher warns and heap dumps go to `logs/` itself, with no per-launch directory and no crash log default; the `JAVA_TOOL_OPTIONS` opt-out below still applies. A path that contains both quote characters gets no heap dump default at all.
+
+Both images declare `VOLUME /hugegraph-server`, so `docker restart` and a Compose recreate keep the files in that anonymous volume. `docker compose down` leaves the volume behind but the next `up` does not attach it, so the files become hard to reach and keep using disk. A later `docker compose down -v` does not remove that orphaned volume either, since it only removes anonymous volumes of the containers it removes; note the volume name with `docker inspect` before `down` and delete it with `docker volume rm `, or stop with `docker compose down -v` in the first place. Avoid `docker volume prune` for this: it deletes every unused anonymous volume on the Docker host, including other projects' data. Mount a named volume or a bind mount at `/hugegraph-server/logs` to keep the files reachable. Kubernetes starts a new container on every restart and does not turn the image's `VOLUME` into a pod volume, so mount an `emptyDir` or a PersistentVolumeClaim at `/hugegraph-server/logs`. Give each pod its own volume, or a per-pod directory on a shared PVC: crash files get distinct names, but `hugegraph-server.log`, the audit log and the slow-query log have fixed names, and pods sharing them would roll them over for each other. For a per-pod directory, pass the pod name in through the Downward API and use it in `subPathExpr` (the Helm chart sets neither):
+
+```yaml
+env:
+ - name: POD_NAME
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.name
+volumeMounts:
+ - name: server-logs
+ mountPath: /hugegraph-server/logs
+ subPathExpr: $(POD_NAME)
+```
+
+The Helm chart in [`helm/`](../helm/hugegraph) does not mount a logs volume yet, so with it these files are lost when a container restarts.
+
+A heap dump can be as large as the JVM heap. Every start uses a new directory and the JVMs a Server starts can dump too, so a Server that keeps running out of memory fills the volume with heap-sized files. Size the volume for the dumps you want to keep plus the logs, and keep it within any `ephemeral-storage` limit or `emptyDir` `sizeLimit`: eviction deletes an `emptyDir` together with the dump in it. Do not use an `emptyDir` with `medium: Memory`, which counts against the memory limit. A liveness probe that restarts the pod while a large dump is being written leaves a truncated file.
+
+The launcher never deletes dumps or `heapdump_*` directories. Each start that gets as far as starting Java leaves a directory behind, empty unless something ran out of memory; the launcher keeps it because a computer-job JVM can go on using it after its Server exits. Move or delete old directories once no HugeGraph JVM from that launch is running, but never the newest one of a running Server: without its directory, the next dump is written as a file with that name and later dumps from that launch fail.
+
+To turn heap dumps off for every JVM, set `JAVA_TOOL_OPTIONS=-XX:-HeapDumpOnOutOfMemoryError`, which also keeps the image's default `JAVA_OPTS`. Set it in the container's environment, not in the host shell: `docker run -e JAVA_TOOL_OPTIONS=...`, an `environment:` entry on the Server service in Compose (the shipped Compose files do not pass it through), or `env` on the container in Kubernetes. Putting the flag in `JAVA_OPTS` turns dumps off for the Server JVM only, because the JVMs it starts do not see its command line, and setting `JAVA_OPTS` replaces the image default (`-XX:+UseContainerSupport -XX:MaxRAMPercentage=50 ...`), so repeat those flags. Either way, each start still creates its empty `heapdump_*` directory, unless creating it failed or the logs path contains a double quote or `%`.
+
## Developers
### Images and Compose files
diff --git a/docs/BUILDING.md b/docs/BUILDING.md
index d4c807c748..d111820fd7 100644
--- a/docs/BUILDING.md
+++ b/docs/BUILDING.md
@@ -3,11 +3,47 @@ Building HugeGraph
Required:
-* Java 11
-* Maven 3.5+
+* Java 17 (currently the only supported Java release)
+* Maven 3.6.3+
+
+The launch scripts enforce Java 17 as the minimum runtime. This check does not
+qualify later Java releases; build HugeGraph with Java 17 unless another
+release is explicitly listed as supported.
+
+The published `hugegraph-common` and `hugegraph-rpc` libraries use Java 11
+bytecode for downstream compatibility. Their build and tests still run on
+Java 17; this library bytecode target does not lower HugeGraph's runtime
+requirement.
+
+See the [Server runtime guide](server-runtime.md#runtime-and-client-configuration) for the
+upgraded runtime, client configuration and compatibility checks.
To build without executing tests: `mvn clean package -Dmaven.test.skip=true`
+## Optional word analyzer
+
+The standard distribution does not include `org.apdplat:word:1.3`. It is a
+provided dependency used to compile and test the optional `word` analyzer.
+The default analyzer remains `ikanalyzer`; the shipped graph configurations
+use `jieba`, and neither requires the word library.
+
+Obtain the GPLv3-licensed external component from
+[Maven Central](https://repo.maven.apache.org/maven2/org/apdplat/word/1.3/word-1.3.jar).
+
+To use `word`, supply `word-1.3.jar` separately in the Server's `lib/` directory
+and restart the Server. Select it in the graph configuration with a supported
+mode, for example:
+
+```properties
+search.text_analyzer=word
+search.text_analyzer_mode=PureEnglish
+```
+
+Selecting `word` without its external library fails with a configuration error
+identifying the missing optional dependency. Other analyzers remain available.
+Applications using Struct directly must add the same dependency to their
+runtime classpath when selecting `word`.
+
## Building in IDEA
To build without executing tests:
@@ -38,4 +74,3 @@ To build without executing tests:
To find the Java binary in your environment, run the appropriate command for your operating system:
* Linux/macOS: `which java`
* Windows: `for %i in (java.exe) do @echo. %~$PATH:i`
-
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index bfa9d7e66f..58bf79c315 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -1,187 +1,90 @@
-# How to Contribute to HugeGraph
+# Contributing to HugeGraph
-> Refer [website-doc](https://hugegraph.apache.org/docs/contribution-guidelines/) for the latest information.
+Keep each contribution focused on one problem, with enough context and validation for review.
+Report reproducible bugs and proposed features through [GitHub issues](https://github.com/apache/hugegraph/issues).
-Thanks for taking the time to contribute!
-As an open source project, HugeGraph is looking forward to being contributed from everyone, and we are also grateful to all the contributors.
+## Prepare a change
-The following is a contribution guide for HugeGraph:
+- Read [AGENTS.md](../AGENTS.md) and the guidance for the modules you will change.
+ The same repository rules apply to changes made by people and coding agents.
+- Work on a topic branch or isolated worktree based on the intended target branch.
+ Preserve existing uncommitted work; stage only files belonging to your change.
+- Check existing implementations and dependencies before adding new abstractions or libraries.
+ Keep unrelated cleanup out of the PR.
+- Follow [.editorconfig](../.editorconfig) and [Checkstyle](../style/checkstyle.xml):
+ four-space indentation, 120-column Java lines, and no wildcard imports.
-
+## Build and validate
-## 1. Preparation
+Use Java 17 and Maven 3.6.3+. Compiler settings and project versions are defined in
+[pom.xml](../pom.xml); module guidance describes test profiles and service prerequisites.
+Run commands from the repository root.
-**Recommended**: You can use [GitHub desktop](https://desktop.github.com/) to greatly simplify the PR process.
+```bash
+# Build the server and its dependencies without running tests
+mvn clean install -pl hugegraph-server -am -DskipTests
-We can contribute by reporting issues, submitting code patches or any other feedback.
-
-Before submitting the code, we need to do some preparation:
-
-1. Sign up or login to GitHub: [https://github.com](https://github.com)
-
-2. Fork HugeGraph repo from GitHub: [https://github.com/apache/hugegraph/fork](https://github.com/apache/hugegraph/fork)
-
-3. Clone code from fork repo to local: [https://github.com/${GITHUB_USER_NAME}/hugegraph](https://github.com/${GITHUB_USER_NAME}/hugegraph)
-
- ```shell
- # clone code from remote to local repo
- git clone https://github.com/${GITHUB_USER_NAME}/hugegraph.git hugegraph
- ```
-
-4. Configure local HugeGraph repo
-
- ```shell
- cd hugegraph
-
- # add upstream to synchronize the latest code
- git remote add hugegraph https://github.com/apache/hugegraph
-
- # set name and email to push code to github
- git config user.name "{full-name}" # like "Jermy Li"
- git config user.email "{email-address-of-github}" # like "jermy@apache.org"
- ```
-
-## 2. Create an Issue on GitHub
-
-If you encounter bugs or have any questions, please go to [GitHub Issues](https://github.com/apache/hugegraph/issues) to report them and feel free to [create an issue](https://github.com/apache/hugegraph/issues/new).
-
-## 3. Make changes of code locally
-
-#### 3.1 Create a new branch
-
-Please don't use master branch for development. Instead, we should create a new branch:
-
-```shell
-# checkout master branch
-git checkout master
-# pull the latest code from official hugegraph
-git pull hugegraph
-# create new branch: bugfix-branch
-git checkout -b bugfix-branch
-```
-
-#### 3.2 Change the code
-
-Assume that we need to modify some files like "HugeGraph.java" and "HugeFactory.java":
-
-```shell
-# modify code to fix a bug
-vim hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeGraph.java
-vim hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeFactory.java
-# run test locally (optional)
-mvn test -Pcore-test,memory
-```
-Note: Code style is defined by the `.editorconfig` file at the repository root. Checkstyle rules are defined in `style/checkstyle.xml`. Configure your IDE accordingly.
-
-##### 3.2.1 Check licenses
-If we want to add new third-party dependencies to the `HugeGraph` project, we need to do the following things:
-1. Find the third-party dependent repository, put the dependent `license` file into [./install-dist/release-docs/licenses/](https://github.com/apache/hugegraph/tree/master/install-dist/release-docs/licenses) path.
-2. Declare the dependency in [./install-dist/release-docs/LICENSE](https://github.com/apache/hugegraph/blob/master/install-dist/release-docs/LICENSE) `LICENSE` information.
-3. Find the NOTICE file in the repository and append it to [./install-dist/release-docs/NOTICE](https://github.com/apache/hugegraph/blob/master/install-dist/release-docs/NOTICE) file (skip this step if there is no NOTICE file).
-4. Execute locally [./install-dist/scripts/dependency/regenerate_known_dependencies.sh](https://github.com/apache/hugegraph/blob/master/install-dist/scripts/dependency/regenerate_known_dependencies.sh) to update the dependency list [known-dependencies.txt](https://github.com/apache/hugegraph/blob/master/install-dist/scripts/dependency/known-dependencies.txt) (or manually update).
-
-**Example**: A new third-party dependency is introduced into the project -> `ant-1.9.1.jar`
-- The project source code is located at: https://github.com/apache/ant/tree/rel/1.9.1
-- LICENSE file: https://github.com/apache/ant/blob/rel/1.9.1/LICENSE
-- NOTICE file: https://github.com/apache/ant/blob/rel/1.9.1/NOTICE
-
-The license information of `ant-1.9.1.jar` needs to be specified in the LICENSE file, and the notice information needs to be specified in the NOTICE file. The detailed LICENSE file corresponding to ant-1.9.1.jar needs to be copied to our licenses/ directory. Finally, update the known-dependencies.txt file.
-
-#### 3.3 Commit changes to git repo
-
-After the code has been completed, we submit them to the local git repo:
-
-```shell
-# add files to local git index
-git add hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeGraph.java
-git add hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/HugeFactory.java
-# commit to local git repo
-git commit
-```
-
-Please edit the commit message after running `git commit`, we can explain what and how to fix a bug or implement a feature, the following is an example:
-
-```sh
-Fix bug: run deploy multiple times
-
-fix #ISSUE_ID
+# Before pushing code, format and compile
+mvn editorconfig:format
+mvn clean compile -Dmaven.javadoc.skip=true
```
-> Please remember to fill in the issue id, which GitHub generated after issue creation.
-
-#### 3.4 Push commit to GitHub fork repo
-
-Push the local commit to GitHub fork repo:
-
-```shell
-# push the local commit to fork repo
-git push origin bugfix-branch:bugfix-branch
+The root POM and child modules flatten CI-friendly versions during `process-resources`,
+so installed and deployed POMs use a concrete version instead of `${revision}`. Build the
+reactor with `install` before building modules separately; keep Maven Resolver validation
+enabled, including on Maven 3.10+.
+
+Run the affected module tests and add regression coverage when behavior changes.
+A successful build with skipped tests does not validate behavior; Commons tests require
+`-DskipCommonsTests=false`. For documentation-only changes, verify links and paths and run
+`git diff --check`. Inspect formatter output before staging to avoid unrelated edits.
+
+See [CI policy](ci.md) for affected-module selection, required checks, result reuse,
+and automatic cancellation and retries. Report what you actually validated and any limitations.
+
+## Documentation and dependencies
+
+- User-visible features, configuration and deployment changes need matching repository
+ documentation in the same PR. When website documentation is affected, link a paired
+ [apache/hugegraph-doc](https://github.com/apache/hugegraph-doc) PR and coordinate both merges.
+ A follow-up issue does not replace the required documentation.
+- In the PR template, use `Doc - TODO` while documentation is pending, `Doc - Done` with
+ its location when ready, or `Doc - No Need` for internal-only changes.
+- New or updated dependencies need appropriate license and notice updates in the root
+ and [release documentation](../install-dist/release-docs/), including bundled license files.
+ Update the [dependency inventory](../install-dist/scripts/dependency/known-dependencies.txt)
+ using [the regeneration script](../install-dist/scripts/dependency/regenerate_known_dependencies.sh).
+
+Install/package the current reactor before regenerating the inventory:
+
+```bash
+mvn clean install -DskipTests -Dmaven.javadoc.skip=true
+bash install-dist/scripts/dependency/regenerate_known_dependencies.sh
```
-Note that since GitHub requires submitting code through `username + token` (instead of using `username + password` directly), you need to create a GitHub token from https://github.com/settings/tokens:
-
-
-## 4. Create a Pull Request
-
-Go to the web page of GitHub fork repo, there would be a chance to create a Pull Request after pushing to a new branch, click the button "Compare & pull request" to do it. Then edit the description for proposed changes, which can just be copied from the commit message.
-
-User-visible feature, configuration or deployment behavior changes must ship with matching
-documentation. Include repository documentation in the same PR. When website documentation
-is affected, link a paired [hugegraph-doc](https://github.com/apache/hugegraph-doc) PR and
-coordinate both merges. A follow-up issue alone does not replace required documentation.
-In the PR template, mark pending documentation as `Doc - TODO` until it is ready;
-use `Doc - Done` with the documentation files or PR link, or `Doc - No Need` for internal-only changes.
-
-Note: please make sure the email address you used to submit the code is bound to the GitHub account. For how to bind the email address, please refer to https://github.com/settings/emails:
-
-
-## 5. Code review
-
- Maintainers will start the code review after all the **automatic** checks are passed:
-
-- Check: Contributor License Agreement is signed
-- Check: Travis CI builds are passed (automatically Test and Deploy)
-
-The commit will be accepted and merged if there is no problem after review.
-
-Please click on "Details" to find the problem if any check does not pass.
-
-If there are checks not passed or changes requested, then continue to modify the code and push again.
-
-## 6. Further changes after review
-
-If we have not passed the review, don't be discouraged. Usually a commit needs to be reviewed several times before being accepted! Please follow the review comments and make further changes.
-
-After the further changes, we submit them to the local repo:
-
-```shell
-# commit all updated files in a new commit,
-# please feel free to enter any appropriate commit message, note that
-# we will squash all commits in the pull request as one commit when
-# merging into the master branch.
-git commit -a
-```
-
-> If there are conflicts that prevent the code from being merged, we need to rebase on master branch:
->
-> ```shell
-> # synchronize the latest code
-> git checkout master
-> git pull hugegraph
-> # rebase on master
-> git checkout bugfix-branch
-> git rebase -i master
-> ```
-
-And push it to GitHub fork repo again:
-
-```shell
-# force push the local commit to fork repo
-git push -f origin bugfix-branch:bugfix-branch
-```
-
-GitHub will automatically update the Pull Request after we push it, wait for code review.
-
-For Any question, please contact us through [dev@hugegraph.apache.org](mailto:dev@hugegraph.apache.org) ([subscriber](https://hugegraph.apache.org/docs/contribution-guidelines/subscribe/) only)
-
-
+Clean rebuilds the current revision's distributions to avoid stale dependency jars.
+It also removes root-level `*.tar.gz` distribution archives; other revision directories remain.
+The build must produce Server, PD and Store distributions for the same current revision.
+It skips tests and is not validation evidence. The inventory combines Maven runtime
+dependencies with flat and nested distribution jars, including Spring Boot `BOOT-INF/lib`
+dependencies inserted by repackaging. Missing distributions fail collection; source or POM
+inspection alone cannot establish the full shipped inventory. Review all additions and
+removals and their license/NOTICE coverage. The dependency check compares the exact inventory;
+repeat it for release platform/profile variants as needed.
+
+## Submit and review
+
+Use `type(scope): description` for commits; omit the scope when it is unclear.
+Types are `feat`, `fix`, `chore`, `docs`, `refactor`, or `BREAKING CHANGE`.
+Start the description with a lowercase verb, keep it under 50 characters, and include
+three to five core change points in the body. For example: `fix(server): handle missing vertices`.
+
+Push your topic branch and open or update a PR against the intended upstream branch.
+Use the [PR template](../.github/PULL_REQUEST_TEMPLATE.md) to explain the problem,
+the resulting behavior, relevant validation, and documentation status. Link the related
+issue when applicable. A short example or diagram is useful when it clarifies the change;
+commit histories and raw test logs are unnecessary.
+
+Address review findings within the agreed scope and validate the affected paths again.
+Resolve a discussion after its issue is fixed or clarified. Inspect failing CI jobs before
+retrying; a rerun is appropriate for a temporary infrastructure failure, not a reproducible bug.
diff --git a/docs/ci.md b/docs/ci.md
new file mode 100644
index 0000000000..6744a1e39f
--- /dev/null
+++ b/docs/ci.md
@@ -0,0 +1,144 @@
+# CI policy
+
+[HugeGraph-Server CI](../.github/workflows/server-ci.yml) is the single entry point for
+PRs, supported branch pushes, weekly coverage and manual validation. Its preflight job
+selects affected tests, reads the project Java version, checks CI policy and verifies
+license headers and RAT. Selected suites run through reusable workflows in the same run.
+
+CI results become advisory after [`.asf.yaml`](../.asf.yaml) is applied. It requests no required CI contexts
+and retains one approving review. This protection change takes effect after the configuration
+is merged and synchronized by Gitbox; verify live protection then. Failed tests keep their
+real failed results. Valid selection and Java outputs allow selected tests to run even when
+the preflight license checks fail.
+
+Preflight keeps its real `check-license` name. The temporary `Server memory tests`
+protection bridge has been removed after confirming both ASF and org master required
+contexts are empty. Actual backend jobs are named `Server memory tests (Java ...)`
+and `Server rocksdb tests (Java ...)`; their native checks report real execution.
+
+## Coverage with selective tests
+
+Each coverage upload has a stable test-group flag. The [Codecov configuration](../.github/codecov.yml)
+enables [carryforward](https://docs.codecov.com/docs/carryforward-flags). When Codecov can
+find an earlier flagged report in its recorded parent chain, it preserves that historical
+report for unexecuted groups such as Commons, PD/Store and backends.
+The PR comment explicitly marks carried-forward data. It is historical coverage,
+not evidence that a skipped test ran; native job failures remain failures.
+
+New flags need a complete initial baseline. The existing weekly full run supplies every
+flagged group for that commit. Master pushes remain smoke-only and upload no coverage;
+those commits can break Codecov's recorded parent chain. A fresh selective PR based on
+such a commit is not guaranteed to inherit the weekly baseline, and its project report
+may still be incomplete. Carryforward within an existing reported PR chain does not
+prove this works for a fresh PR. Full and selected runs retain explicit report paths and
+uploads; no extra collector runner or repeated master coverage tests are added.
+
+## What runs
+
+| Validation | Ordinary PR | Push to master | Weekly full run |
+| --- | --- | --- | --- |
+| Memory and RocksDB | Full unit, core and API tests when affected | No repeated full tests | Full tests |
+| macOS ARM | Startup, JNI, graph read/write and restart smoke when affected; full core/API for platform inputs | Affected smoke | Full core/API |
+| HBase and RISC-V | Directly related backend/platform or validation inputs only | No repeated tests | Full validation |
+| PD, Store, HStore and Cluster | Affected modules and known consumers | Current-commit distributed smoke | Full group |
+| CodeQL | Authentication, authorization, security, schema cache transactions, POM or CodeQL configuration inputs | No repeated scan | Full scan |
+| Docker, Helm and dependency audit | Corresponding inputs | Corresponding deployment smoke or checks | Full validation |
+
+macOS uses one `macos-15` ARM job for either smoke or full validation. Intel macOS is removed.
+The weekly run uses `cron: '0 6 * * 1'` with `timezone: Asia/Singapore`: Monday at 06:00,
+on the latest default-branch commit. GitHub may delay scheduled runs during high load;
+see [schedule behavior](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule).
+
+### Affected inputs
+
+The rules live in [ci-policy.py](../.github/scripts/ci-policy.py). Ordinary Server changes
+select both Memory and RocksDB, plus ARM smoke. They do not select the complete PD/Store group.
+PD and Store changes select their own tests and downstream consumers; Commons and Struct
+select their known shared consumers. HStore and Cluster additionally follow distributed
+backend contracts, protocols, metadata, graph lifecycle and scheduling inputs. Distribution,
+startup and RocksDB inputs select ARM full validation and their deployment consumers.
+
+Unclassified paths or an unavailable diff/API conservatively select mainline coverage.
+HBase and RISC-V remain limited to explicit related inputs, weekly coverage or manual selection.
+The classifier uses paths, not PR titles or semantic interpretation of changed text.
+Contract scripts explicitly select their executing suites without adding runtime consumers.
+
+### Documentation
+
+Documentation-only ordinary PRs and pushes allocate just the preflight CI runner. This count
+excludes the non-test [Auto PR Commenter](../.github/workflows/auto-pr-review.yml), which runs
+once when a PR opens. Scheduled and manual selections still run their requested suites.
+
+Plain documentation includes regular-file root prose, README/AGENTS files and supported prose
+or static images under `docs/`, including nested module documentation. Runtime resources,
+configuration, source, fixtures, templates and executable or symbolic-link files do not qualify;
+for example, a test fixture ending in `.txt` still selects its module's tests.
+
+## Manual validation and reruns
+
+Use **Actions → HugeGraph-Server CI → Run workflow**, select a branch and choose `test-set`:
+
+| Test set | Validation |
+| --- | --- |
+| `mainline` (default) | Full mainline modules, deployment/dependency checks and CodeQL; ARM smoke |
+| `distributed` | Struct, PD, Store, HStore and Cluster, plus distributed smoke |
+| `macos-arm` | ARM smoke and full RocksDB core/API tests |
+| `hbase`, `riscv64`, `codeql` | The corresponding complete specialist validation |
+| `full` | All retained suites, identical to weekly coverage |
+
+Fork contributors run additional validation in their fork's Actions. The manual entry point
+does not accept a PR number, apply labels or reuse another workflow's results.
+Automatic retries are disabled. Use GitHub's native rerun controls for failed jobs or a run;
+a rerun retains its original commit and event, subject to the preflight freshness checks.
+
+## Java runtime compatibility
+
+A positive suite passes only when its tests succeed and its current-run output has no
+Java runtime compatibility errors. Server, PD/Store, Commons, Struct and Cluster test
+steps capture both stdout and stderr before running `check-java-runtime-logs.sh`.
+Startup and Console smoke tests also inspect their output before cleanup. Cluster nodes
+forward their text runtime logs to the captured suite output before deletion.
+Missing or unreadable required logs fail the check.
+
+The gate rejects denied module access and unknown or removed JVM options, including
+exceptions caught and printed by dependencies. Ordinary business errors and deliberate
+negative fixtures are not compatibility failures. Native smoke additionally uses `--native`
+for its existing native linkage checks.
+
+Launchers and tests share each component's `jvm-module.options`: PD and Store open
+`java.lang` for Hessian, while Console and dump-store reuse Server's existing options.
+Required module options are independent of custom `JAVA_OPTIONS` or `-j` flags; Surefire
+retains the JaCoCo agent. CI continues to use the project Java runtime and its existing
+suite-selection and required-check policy.
+
+
+## Freshness, runner use and budgets
+
+PR selection uses the cumulative diff against the checked-out merge commit's base. A source
+PR followed by a documentation-only commit still tests the cumulative source changes.
+Preflight verifies that the event merge commit's second parent matches the current PR head.
+A stale PR head fails preflight. Target-branch advancement alone does not invalidate a run.
+Plans and selection summaries describe this run; previous results are never reused.
+
+A new PR head cancels the previous PR run, including its called workflows. Manual and weekly
+runs remain independent. Each job reports its own result; there are no separate post-test
+aggregation runners or end-of-run metadata checks.
+
+Startup contracts run inside RocksDB validation, and Store stop contracts run inside Struct.
+Docker's static checks, deployment contracts and current-image validation share one runner.
+The Docker job has a 90-minute total budget, with 25 minutes for deployment contracts and
+45 minutes for image build/runtime validation. Distributed smoke builds current PD, Store
+and Server images and checks authentication, readiness and graph read/write. It does not
+substitute published images for the current commit.
+
+PD, Store and Commons have total job budgets of 30, 45 and 20 minutes. Cluster has 120 minutes
+for its two 45-minute test windows and build/diagnostics. Other budgets are in the workflows.
+TinkerPop suites keep their dedicated branch policy: the source, target or push/manual branch
+must start with `release-`, `test-` or `tinkerpop-`. Executed suites require non-empty reports
+with actual tests.
+
+Each separate job or matrix item requests a runner; steps within a job share one.
+[Standard hosted runners are free for public repositories](https://docs.github.com/en/actions/reference/runners/github-hosted-runners#standard-github-hosted-runners-for-public-repositories),
+but [plan-dependent total and macOS concurrency limits](https://docs.github.com/en/actions/reference/limits#job-concurrency-limits-for-github-hosted-runners)
+still apply. Reducing job allocations and long executions lowers shared-pool demand;
+it does not guarantee a specific queue delay.
diff --git a/docs/cypher-api.md b/docs/cypher-api.md
new file mode 100644
index 0000000000..12bad68e94
--- /dev/null
+++ b/docs/cypher-api.md
@@ -0,0 +1,191 @@
+# Cypher API response compatibility
+
+The Cypher endpoint is
+`/graphspaces/{graphspace}/graphs/{graph}/cypher`. Submit one statement per
+request. GET accepts the `cypher` query argument and does not accept a parameter
+map; use POST for parameterized queries. POST with `Content-Type: application/json`
+accepts a JSON object containing a nonblank string `cypher` and an optional
+object `parameters`, for example:
+
+```json
+{
+ "cypher": "MATCH (n:person) WHERE n.name = $name RETURN n.name",
+ "parameters": {"name": "marko"}
+}
+```
+
+Legacy raw Cypher bodies sent as `application/json` remain accepted. JSON arrays,
+scalars, malformed JSON, missing/non-string/blank `cypher`, and a `parameters`
+value of null, an array or a scalar are rejected by HTTP request validation.
+Omitting `parameters` supplies an empty map. Additional request fields and extra
+parameter names are allowed; every parameter referenced by the parsed query must
+be present. Individual parameter values may explicitly be null.
+Requests are limited to 16 top-level parameter names. This limit is fixed; see
+[Cypher HTTP contracts](server-runtime.md#cypher-http-contracts) for the binding
+limits and validation rules.
+
+The Server checks missing bindings before executing the traversal, so GET, raw
+POST and JSON POST with an absent referenced name fail with `Missing Cypher
+parameter: name`. This uses the execution-failure envelope below, classified as
+`HugeGraph.Cypher.ExecutionError` with an empty hint. The upstream translator's
+standalone unbound-to-null behavior is not the Server request contract. Do not
+send `?parameters=...` to bind a GET query. Invalid binding values, including the
+reserved translator null marker, also fail before traversal execution; explicit
+JSON null values are accepted and normalized by the Server.
+
+## Authentication
+
+Cypher follows the Server authentication mode. When authentication is disabled,
+GET and POST requests do not require an `Authorization` header, and the Server
+uses an anonymous Gremlin connection. Both the request's `Authorization` header
+and credentials in `conf/remote-objects.yaml` are ignored. Mixed modes with REST
+authentication disabled and Gremlin authentication enabled are unsupported.
+When authentication is enabled, requests still need
+valid Basic or Bearer credentials and retain the caller's graph permissions.
+Use `bin/enable-auth.sh` to configure authentication consistently for both REST
+and Gremlin; enabling only the REST authenticator does not protect the Gremlin
+endpoint used to execute Cypher.
+
+The Gremlin host and port in `conf/remote-objects.yaml` must point to the same
+Server's Gremlin endpoint, including when using nondefault ports.
+
+The API test runner exercises anonymous GET and POST before enabling
+authentication, then runs the authenticated suite, including missing-header
+rejection and a read-only user's write and cross-graph rejection cases.
+
+## Response envelope
+
+Responses retain the Gremlin-compatible top-level fields `requestId`, `status`
+and `result`. There is no top-level `errors` field. Existing Java clients can
+continue to deserialize both successful and failed queries without an upgrade.
+
+A successful query has `status.code = 200`, an empty `status.attributes` map,
+and its rows in `result.data`, for example:
+
+```json
+{
+ "requestId": "example-request",
+ "status": {"message": "", "code": 200, "attributes": {}},
+ "result": {"data": [{"value": 1}], "meta": {}}
+}
+```
+
+When query submission or execution fails inside the Cypher client, the existing
+HTTP 200 / `status.code = 400` convention and `status.message` are preserved.
+Authentication, request validation and other HTTP-layer errors remain separate
+and need not use this envelope. Consumers must inspect `status.code` instead of
+relying only on the HTTP status.
+
+Structured error metadata lives at `status.attributes.errors`, an array with
+one entry for the failed query. Each entry contains a stable string `code`, the
+translator/execution `message`, and a `hint` (empty when no specific guidance is
+available). Message wording is not a stable contract. Example:
+
+```json
+{
+ "requestId": "example-request",
+ "status": {
+ "message": "Expected exactly one statement per query but got: 2",
+ "code": 400,
+ "attributes": {
+ "errors": [{
+ "code": "HugeGraph.Cypher.SyntaxError",
+ "message": "Expected exactly one statement per query but got: 2",
+ "hint": "Submit exactly one Cypher statement per request"
+ }]
+ }
+ },
+ "result": {"data": null, "meta": {}}
+}
+```
+
+Clients that only consume `status.code`, `status.message` and `result` can ignore
+the attributes. A failure with no mapped metadata may have empty attributes.
+
+## Error codes and hints
+
+| Code | Recognized failure | Hint |
+| --- | --- | --- |
+| `HugeGraph.Cypher.SyntaxError` | Parser invalid input, unknown function or semantic error with a `(line N, column M (offset: K))` suffix | Check the query syntax and function names supported by the built-in Cypher translator |
+| `HugeGraph.Cypher.SyntaxError` | More or fewer than one statement | Submit exactly one Cypher statement per request |
+| `HugeGraph.Cypher.SyntaxError` | `Variable` followed by a backtick-quoted name and `not defined` | Declare the variable in a MATCH, UNWIND or WITH clause before referencing it |
+| `HugeGraph.Cypher.ExecutionError` | Undefined vertex label, edge label, property key or index label | Create the schema element via the schema API before running the query |
+| `HugeGraph.Cypher.UnsupportedFeature` | Translator messages beginning `Unsupported clause:`, `Unsupported match pattern:`, `Unsupported create pattern:`, `Unsupported property type:`, `Multiple labels are not supported:` or `Custom functions and predicates are not supported on target implementation:` | This construct is not covered by the built-in translator, see the Cypher compatibility guide for supported syntax |
+| `HugeGraph.Cypher.ExecutionError` | Other failures, including unrecognized translation failures | Empty string |
+
+`ExecutionError` is also the fallback for unrecognized translation failures; it
+does not mean traversal execution has started. For example, `reduce(...)` or
+semantic rejections without a recognized position suffix may return this code
+with an empty hint while preserving the original failure message. Classification
+recognizes only the translation-1.0.4 forms above and does not cover every
+translator failure; runtime errors such as `Unsupported predicate` and
+`CONTAINS query ... is not supported` also fall back.
+There is no `HugeGraph.Cypher.MissingParameter` contract.
+
+This contract repairs the unreleased proposal in
+[Server #3241](https://github.com/apache/hugegraph/pull/3241). The paired website
+work is [Doc #499](https://github.com/apache/hugegraph-doc/pull/499); its author
+must incorporate this response contract before coordinated publication.
+Parameterized POST requests are already provided by merged
+[Server #3289](https://github.com/apache/hugegraph/pull/3289).
+The earlier [bound-query fork #238](https://github.com/hugegraph/hugegraph/pull/238)
+is a historical reference; use the current request contract above.
+
+## Temporal functions
+
+The built-in translator has no temporal functions of its own. The Server folds
+the `date()`, `datetime()` and `localdatetime()` constructors into native date
+values before translation, so these calls work wherever a bound value is
+accepted, for example `MATCH (n) WHERE n.birthday = date('2000-01-01') RETURN n`.
+A constructor folds when it has no argument (the current date or date-time), a
+string literal argument, or a parameter bound to a string; `date()` uses the
+`yyyy-MM-dd` form and the other two use `yyyy-MM-ddTHH:mm:ss[.SSS]`. Arguments
+that are not foldable, other temporal forms such as map or duration arguments,
+and the remaining temporal functions (`time()`, `localtime()`, `duration()`)
+keep failing translation with `Unknown function` as before.
+Constructor names are case-insensitive like the rest of Cypher, and the bare
+constructors of one statement read the clock once. EXPLAIN prints a folded
+value as an explicit `new Date(...)` constructor instead of a stringified date.
+
+## Regression checks
+
+Run the mapper, real-parser and envelope tests from the repository root:
+
+```sh
+mvn test -pl hugegraph-server/hugegraph-test -am -P unit-test \
+ -Dtest=CypherErrorTest,CypherClientTest,CypherOpProcessorTest \
+ -Dsurefire.failIfNoSpecifiedTests=false
+```
+
+`CypherApiTest` also exercises malformed syntax, multiple statements, undefined
+variables and missing schema through a running test server. Its `testParameters`
+and `testMissingParameterHasFallbackError` cover bound JSON requests and missing
+bindings. `CypherClientTest` checks outgoing query/binding transport, and
+`CypherOpProcessorTest` covers request binding validation. The mapper's
+translator-only unbound-parameter test bypasses that validation. Follow the normal
+Server API-test setup using disposable test data.
+
+The standalone `CypherClientCompatibilityTest` uses the actual Java Client
+`Response` and `RestResult` classes. It is outside the Server reactor's source
+roots to avoid a Server-to-Client dependency cycle. Supply an existing Client
+jar (checked with locally available 1.7.0 and development 1.8.0 artifacts) and run from the repository root:
+
+```sh
+mvn -f hugegraph-server/hugegraph-api/pom.xml dependency:build-classpath \
+ -Dmdep.outputFile="$PWD/target/cypher-classpath.txt"
+CYPHER_CLIENT_JAR="$HOME/.m2/repository/org/apache/hugegraph/hugegraph-client/1.7.0/hugegraph-client-1.7.0.jar"
+CYPHER_CP="$CYPHER_CLIENT_JAR:$(cat target/cypher-classpath.txt)"
+mkdir -p target/cypher-compatibility
+javac -encoding UTF-8 -proc:none -cp "$CYPHER_CP" \
+ -d target/cypher-compatibility \
+ hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherModel.java \
+ hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherErrorMapper.java \
+ hugegraph-server/hugegraph-test/src/compatibility/java/CypherClientCompatibilityTest.java
+java -cp "target/cypher-compatibility:$CYPHER_CP" \
+ org.junit.runner.JUnitCore CypherClientCompatibilityTest
+```
+
+These checks cover legacy envelopes, current success/failure deserialization,
+status/message preservation, and a negative control proving that a top-level
+`errors` field is rejected. They do not certify a live Hubble deployment or
+matching-release-candidate acceptance.
diff --git a/docs/images/switch-rocksdb-to-toplingdb.png b/docs/images/switch-rocksdb-to-toplingdb.png
new file mode 100644
index 0000000000..5f6d92c1cf
Binary files /dev/null and b/docs/images/switch-rocksdb-to-toplingdb.png differ
diff --git a/docs/same-neighbors-batch.md b/docs/same-neighbors-batch.md
new file mode 100644
index 0000000000..2d5777c54f
--- /dev/null
+++ b/docs/same-neighbors-batch.md
@@ -0,0 +1,38 @@
+# Pairwise same neighbors
+
+`POST /graphspaces/{graphspace}/graphs/{graph}/traversers/sameneighborsbatch`
+computes the common neighbors of each vertex pair independently.
+It uses the graph and graphspace permission checks of other traverser APIs.
+
+```json
+{
+ "vertex_list": [["vertex-a", "vertex-b"], ["vertex-c", "vertex-d"]],
+ "direction": "BOTH",
+ "label": "knows",
+ "max_degree": 10000,
+ "limit": 100
+}
+```
+
+`vertex_list` must be present, non-null, and non-empty. Each entry must contain exactly two
+non-null vertex IDs, using the numeric/string ID representation. Numeric IDs must
+use JSON integer syntax, between -9223372036854775808 and 9223372036854775807.
+Repeated pairs remain repeated results.
+All pairs are validated before traversal. Missing vertices and unsupported ID
+values are rejected through the existing argument error handling.
+
+`direction` defaults to `BOTH`; allowed values are `OUT`, `IN`, and `BOTH`.
+`label` is optional and filters edges for each pair. `max_degree` defaults to
+10000 and must be positive or `-1` (unlimited). `limit` defaults to 10000000,
+applies separately to each pair, and must be positive up to 2147483647 or `-1`.
+The API does not introduce a separate batch-size parameter; callers should bound
+the number of pairs and use finite degree/limit values to bound traversal work.
+
+The response contains `same_neighbors`, an array of arrays in input-pair order,
+and `measure`, containing traversal iteration counts and elapsed time.
+A pair with no common neighbors contributes an empty array. Neighbor order within
+a pair is unspecified. Iteration counts cover all pairs once.
+
+The existing `/traversers/sameneighbors` endpoints remain unchanged: GET computes
+the intersection for two vertices, while POST computes one intersection across
+all vertices in its flat `vertex_list`. Their response shapes are unchanged.
diff --git a/docs/server-runtime.md b/docs/server-runtime.md
new file mode 100644
index 0000000000..e8d0283855
--- /dev/null
+++ b/docs/server-runtime.md
@@ -0,0 +1,891 @@
+# Server runtime and query contracts
+
+This guide covers Server configuration, wire formats, query semantics and transaction
+ownership. Use it when upgrading applications or changing the
+Server runtime. Deployment instructions remain in the [build guide](BUILDING.md)
+and [Server module guidance](../hugegraph-server/AGENTS.md).
+
+## Runtime and client configuration
+
+The runtime uses TinkerPop 3.8.1 and Groovy 4.0.25. Build and run every module with Java
+17. Applications migrating from TinkerPop 3.5.1 must review the full version interval,
+including the configuration changes below.
+
+### Required JVM module options
+
+Server, store initialization, store dumping and Gremlin Console always load the
+distribution's `bin/jvm-module.options`. Keep this file with the shipped scripts;
+each launcher rejects a missing or unreadable file. Required module permissions
+are independent of caller options, so custom `JAVA_OPTIONS` or the Server launcher's
+`-j` option do not replace them. Gremlin Console retains its existing
+`JAVA_OPTIONS` defaults; store dumping retains its existing command-line interface.
+The shared file opens `java.nio` for HBase's reflective `java.nio.Bits.unaligned()`
+check during `HBasePlatformDependent` initialization, including on x86 Java 17.
+It also opens `java.lang` for SOFA RPC's `ProviderInvoker` initialization, which
+reflects on `Throwable.cause`.
+
+PD and Store use their own `bin/jvm-module.options` for the module permission needed
+by Hessian serialization on Java 17. Their tests and cluster launchers use the same
+component files. CI checks positive-test and startup logs for Java module-access
+errors and unsupported JVM options, including errors printed without failing the
+operation.
+
+### Launcher GC options
+
+Use `bin/start-hugegraph.sh -g g1` (or `-g G1`) to select G1 explicitly.
+`-g zgc` and `-g ZGC` select ZGC; other values are rejected. Without `-g`, the
+launcher preserves the JVM's collector choice or the collector set by the caller in
+`JAVA_OPTIONS`. When `JAVA_OPTIONS` is empty, `-j "JVM_OPTIONS"` supplies the caller
+options instead.
+
+For the default or G1 path, the launcher supplies `-XX:+ParallelRefProcEnabled`,
+`-XX:InitiatingHeapOccupancyPercent=50` and `-XX:G1RSetUpdatingPauseTimePercent=5`
+before the caller's options, so caller values override these tuning defaults. For
+example, `bin/start-hugegraph.sh -g g1 -j "-XX:InitiatingHeapOccupancyPercent=45"`
+uses G1 with an occupancy threshold of 45 when `JAVA_OPTIONS` is empty.
+
+Do not combine explicit `-g` selection with a different collector in `JAVA_OPTIONS`
+or `-j`; the JVM rejects conflicting collector selections.
+
+### Launcher memory budget
+
+The default launcher heap uses half the smaller of host available memory and the
+remaining budget under visible cgroup v1/v2 hard limits, with a 512 MiB minimum and a
+32 GiB maximum. It refuses startup when the effective budget is below 512 MiB.
+If a known hard limit has missing, unreadable or invalid usage, the budget is zero;
+the launcher refuses startup instead of using host memory. For cgroup v1, numeric
+unlimited values are recognized only when `uname -m` reports `x86_64`, `aarch64` or
+`riscv64` and `getconf PAGESIZE` returns a positive integer. The threshold is the
+signed 64-bit maximum rounded down to that page size. Recognized unlimited levels
+are skipped so readable finite ancestors still apply; without a finite limit,
+host sizing remains available. Unknown or masked machine names, or an unavailable
+or invalid page size, keep numeric limits on the finite-limit path.
+
+Inactive file cache can contribute to this budget on a warm restart. For a finite
+cgroup v2 limit, the launcher checks the immediate cgroup children of that reclaim
+target. If any child's `memory.min` is nonzero, missing, invalid or unreadable, it
+excludes cache credit at that limit: child cache may be protected from ancestor reclaim.
+If the target directory cannot be enumerated, or a child cannot be searched, cache
+credit is also excluded because child protection cannot be determined. Ordinary
+searchable directories without cgroup controller files are skipped.
+Unprotected children retain the existing warm-cache budget, including inherited and
+nested hard limits. A cgroup's own `memory.min` does not block cache credit for its
+own limit; another ancestor can still constrain it through child protection.
+The cgroup v1 cache calculation is unchanged.
+
+This is a startup estimate, not a guarantee against runtime OOM. Native allocations
+and concurrent processes also consume memory. Explicit heap options retain their
+existing precedence; see [container heap sizing](../docker/README.md#server-heap-sizing).
+
+### Arthas diagnostics
+
+Server and Store bundle Arthas 4.1.5, managed by the root POM. PD does not embed
+an Arthas agent. Diagnostic ports bind to `127.0.0.1` by default. Administrators
+can explicitly configure another valid address, including wildcard `0.0.0.0` or
+`::`; `localhost` is normalized to `127.0.0.1`. Wildcard listeners can accept both
+IPv4 and IPv6 depending on the OS/JVM configuration; restrict both in firewall
+rules. Server's `PUT /arthas`
+requires authentication to be enabled and the caller to be an authenticated administrator.
+Without authentication, the Server endpoint returns HTTP 403, including requests
+from localhost or through a reverse proxy. Store has no equivalent administrator
+authentication, so `GET /v1/arthasstart` always returns HTTP 403.
+
+For local diagnosis of either running service, use its distribution's bundled
+helper as the same OS user that owns the JVM:
+
+```bash
+bin/arthas.sh
+```
+
+The distribution prepares the agent in `lib/arthas` at build time. The helper
+requires JDK 17 and uses `bin/pid`, without downloads, runtime extraction, a user
+cache, service restarts or configuration edits. A new local agent listens on
+`127.0.0.1:3658`, with HTTP, MCP and reverse tunnels disabled. Use `--pid` or
+`--telnet-port` for another running instance, and `--command version` for a one-time
+command. Interactive mode requires a terminal; for pipes, scripts or `docker exec`
+without `-it`, use `--command` instead. Run `bin/arthas.sh --help` for usage.
+When the helper exits, including after `--command`, `quit`, or INT/TERM/HUP, it
+stops the agent and all its diagnostic sessions; the application JVM keeps running.
+SIGKILL or a host failure cannot run this cleanup: stop any surviving agent manually.
+For remote diagnosis, log in to the service host over SSH and run the same command,
+or explicitly configure a reachable listener with appropriate authentication and
+network access controls as described below.
+Official Server (including HStore) and Store images include JDK 17, so the same
+helper works inside the container, for example
+`docker exec -it /hugegraph-store/bin/arthas.sh`.
+A JRE-only installation needs JDK 17 for this helper.
+If the Server API has already activated Arthas, connect to its existing telnet
+port instead: `bin/arthas.sh --telnet-port 8562` with the default configuration.
+Use `--target-ip` to select another bind address, for example
+`bin/arthas.sh --target-ip 0.0.0.0` or
+`bin/arthas.sh --target-ip ::1 --telnet-port 8562 --command version`.
+The default is `127.0.0.1`; `localhost` maps to `127.0.0.1`. Wildcard addresses
+`0.0.0.0` and `::` may accept both IPv4 and IPv6; the helper connects and cleans
+up through `127.0.0.1` for either wildcard. Remote clients connect
+to the service host's actual reachable address, not the wildcard address.
+An already running agent retains its listener and authentication settings;
+helper options do not reconfigure it. To switch to the helper's
+HTTP-disabled defaults, run `bin/arthas.sh --telnet-port 8562 --command stop`, then
+`bin/arthas.sh`; this stops only Arthas, and leaves the service JVM running.
+Arthas can execute code inside the JVM. OS attach permissions control initial
+activation; HugeGraph administrator authentication protects only the activation
+API, not subsequent diagnostic connections. We recommend enabling Arthas native
+authentication, especially when binding beyond loopback. However, native defaults
+allow connections from `127.0.0.1` to skip authentication even with credentials;
+other OS users or containers in the same Pod can still connect this way. To disable
+this bypass, set `-Darthas.localConnectionNonAuth=false` on the service JVM and
+restart it. Restrict host/Pod and port-forward access to trusted operators, and
+limit firewall rules, port mappings and SSH forwarding accordingly. Authentication
+does not encrypt telnet
+or ordinary HTTP traffic; use a protected network or encrypted transport for
+remote access. Avoid concurrent helper sessions because exiting one stops the
+shared agent. A disabled-command list is optional and is not access control.
+
+Pass native Arthas credentials when starting or reusing an agent:
+
+```bash
+bin/arthas.sh --target-ip 0.0.0.0 \
+ --username diagnostics --password ''
+```
+
+Use single-line credentials; printable ASCII avoids the bundled Arthas 4.1.5
+telnet client's Unicode limitation. Password-only use defaults to username `arthas`.
+Native JVM credentials also apply to helper-started agents: if the target JVM has
+`-Darthas.username` or `-Darthas.password`, supply the effective credentials with
+`--username` / `--password` for authenticated commands and cleanup, especially
+when `-Darthas.localConnectionNonAuth=false` is set. The helper does not clear
+native credentials when the corresponding option is omitted.
+These credentials are separate from HugeGraph accounts. Batch commands and exit
+cleanup use the supplied credentials automatically. For interactive diagnosis,
+if prompted, enter `auth --username diagnostics ` in the Arthas
+console. Existing agents require their existing credentials; passing new values
+does not change them. Password arguments may appear in shell history and process
+listings, so handle them on a trusted host. With `--username` alone, native Arthas
+generates a password only when none is already configured in the target JVM's
+native settings; otherwise it uses that inherited password. Supply `--password` for
+authenticated batch commands and automatic cleanup. If cleanup fails, the helper
+returns nonzero and the agent may remain running; use the configured or generated credentials with
+native `auth`, then `stop`, to close it without stopping the service JVM.
+
+[Arthas native authentication](https://arthas.aliyun.com/en/doc/auth.html) remains
+optional and follows upstream defaults: `arthas.localConnectionNonAuth=true`
+allows connections from `127.0.0.1` to skip authentication. The helper uses a private
+configuration, so editing `lib/arthas/arthas.properties` does not configure it.
+If required, the target JVM property `-Darthas.localConnectionNonAuth=false` is
+honored after restarting the service JVM. Arthas 4.1.5 generates a password when
+binding `0.0.0.0` or `::` without an effective native password and prints it in the target
+JVM output and Arthas log; explicitly supplying credentials is more convenient
+for remote diagnosis and authenticated cleanup.
+
+Authenticated Server administrators can continue using `PUT /arthas` without an
+additional enable switch or restart. Its HTTP/telnet interfaces use the configured
+bind address; HugeGraph disables the optional MCP endpoint. The activation response
+still contains the same four Arthas configuration keys. For API activation with
+native credentials, add `-Darthas.username=diagnostics` and
+`-Darthas.password=` to the Server JVM startup options and restart
+Server before activation. These are native JVM properties, not new REST
+configuration keys, and also apply to agents started by the local helper as described
+above. They may expose secrets in process listings. Without native
+credential settings, a specific non-loopback bind address does not require login; wildcard
+binds use the generated password described above. API activation alone
+leaves the agent running until an operator sends `stop`; connecting through the
+helper applies the helper's exit cleanup to that existing agent too.
+
+Before reusing an API-started agent, leave `session` and `stop` out of
+`arthas.disabled_commands` (or its legacy alias). `session` verifies the target
+PID; `stop` closes Arthas without stopping the service JVM. Existing configurations
+remain accepted, but failed PID verification or listener cleanup makes the helper
+print an error and exit nonzero; the agent may remain running. To recover from an
+agent with `stop` disabled, remove that restriction and restart the service before
+activating Arthas again. Newly attached local agents keep both commands available.
+
+No commands are disabled by default. Administrators can optionally set
+`arthas.disabled_commands=jad,mc` to restrict selected commands for API activation;
+new local agents do not inherit this list.
+
+The existing `arthas.ip`, `arthas.httpPort`, `arthas.telnetPort` and
+`arthas.disabledCommands` options configure the Server API-activated agent.
+The distribution uses `arthas.telnet_port`, `arthas.http_port` and
+`arthas.disabled_commands`. These snake_case keys take precedence over the legacy
+camelCase aliases when both are present, including explicit default values.
+Conflicting values produce a warning naming both keys and the effective key when
+the agent is activated. Remove the unused alias to avoid ambiguity. Store's legacy
+configuration fields are retained; the local helper does not read them.
+
+For a synchronous one-time command, POST `{"action":"exec","command":"version"}`
+to the agent's `/api` endpoint without a `sessionId`. Check `state=SUCCEEDED`, the
+`body.results` version and command status, and `body.jobStatus=TERMINATED`; HTTP 200
+alone also occurs for failed commands. Arthas 4.1.5 includes the upstream
+[terminal-process interrupt fix](https://github.com/alibaba/arthas/pull/3079) and
+[foreground-job cleanup fix](https://github.com/alibaba/arthas/pull/3094), so cleanup
+can remove a session whose foreground command has already terminated.
+It also includes the [MCP-disabled HTTP handler fix](https://github.com/alibaba/arthas/pull/3127).
+
+Clients that explicitly call `init_session` must call `close_session` when finished.
+After closing, `session_info` for that ID reports `state=FAILED` with `session not found`.
+[`ArthasApiTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/ArthasApiTest.java)
+covers one-time execution, explicit session execution and removal.
+
+### Serializer configuration
+
+Use the Gremlin configuration files shipped with the upgraded Server. Serializer classes
+move from `org.apache.tinkerpop.gremlin.driver.ser` to
+`org.apache.tinkerpop.gremlin.util.ser`. The supplied remote configurations include the
+corresponding HugeGraph registries.
+
+The Java Driver sample `gremlin-driver-settings.yaml` and Gremlin Console's
+`remote.yaml` now default to GraphBinary V1. The serializer entry changes as follows:
+
+```diff
+- className: org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV1d0
++ className: org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV1
+```
+
+Keep the HugeGraph builder and registry in both client configurations:
+
+```yaml
+serializer:
+ className: org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV1
+ config:
+ serializeResultToString: false
+ builder: org.apache.hugegraph.io.HugeGraphTypeSerializerRegistryBuilder
+ ioRegistries: [org.apache.hugegraph.io.HugeGraphIoRegistry]
+```
+
+GraphBinary preserves graph elements and UUID IDs for Java Driver accessors such as
+`Result.getVertex()` and `Result.get(UUID.class)`. Untyped GraphSON V1 decodes these
+results as maps and strings.
+
+`remote-objects.yaml` serves the Cypher HTTP JSON path and continues to use
+`org.apache.tinkerpop.gremlin.util.ser.GraphSONUntypedMessageSerializerV1` with
+`HugeGraphIoRegistry`. It retains its duplicate registry entry as a TinkerPop driver
+compatibility workaround.
+
+For the server, keep the GraphBinary builder and registry. The serializer order is
+GraphBinary, untyped GraphSON V1/V2/V3, then typed GraphSON V2/V3. The GraphSON entries
+keep the HugeGraph registry. The first entries look like this:
+
+```yaml
+serializers:
+ - className: org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV1
+ config:
+ serializeResultToString: false
+ builder: org.apache.hugegraph.io.HugeGraphTypeSerializerRegistryBuilder
+ ioRegistries: [org.apache.hugegraph.io.HugeGraphIoRegistry]
+ - className: org.apache.tinkerpop.gremlin.util.ser.GraphSONUntypedMessageSerializerV1
+ config:
+ serializeResultToString: false
+ ioRegistries: [org.apache.hugegraph.io.HugeGraphIoRegistry,
+ org.apache.hugegraph.io.HugeGraphSONV1MessageIoRegistry]
+```
+
+The full ordering is in `gremlin-server.yaml`. `application/json` selects untyped V1 in
+the supplied server configuration variants.
+
+Typed GraphSON is available for the supported simple values and IDs, not as a
+replacement for untyped serialization of every HugeGraph schema or graph object. Typed
+V1 is not registered: its legacy `@class` deserialization can construct Java objects
+before request authentication. V2/V3 use `@type`/`@value`.
+
+GraphBinary uses `HugeGraphTypeSerializerRegistryBuilder` to write HugeGraph IDs as
+their underlying values. Schema results use standard maps and Blob values use standard
+binary values (`ByteBuffer` in the Java Driver), including when nested in returned
+vertices, edges or collections. `Optional` results carry their value or `null` when
+empty, including results from `graph.variables().get()`. `File` results retain the map
+shape `{"file": "name"}` using the file name, including inside nested results. Untyped
+GraphSON V1 keeps the legacy Tree array of `key`/`value` entries and accepts scalar as
+well as element keys through the additional `HugeGraphSONV1MessageIoRegistry` in that
+serializer's configuration. Keep this registry limited to untyped V1 messages. Standard
+graph-file IO and typed GraphSON serializers keep `HugeGraphIoRegistry` alone and use
+the TinkerPop Tree format for their negotiated version.
+
+HugeGraph enums such as `DataType.TEXT` and `Directions.OUT` return their names over
+GraphBinary, including in maps and lists. `graph.schema()` returns a map with
+`propertykeys`, `vertexlabels`, `edgelabels` and `indexlabels` lists, using the same
+schema conversion. TinkerPop enums retain their native wire types. Backend `Shard`
+values, including `graph.metadata(HugeType.VERTEX, 'splits', size)`, return standard
+maps with `start`, `end` and an exact 64-bit `length`, including inside lists and maps.
+The default Driver does not need a private Shard serializer.
+
+Local edge ID filters accept both serialized strings and native edge IDs in mixed
+collections: `hasId(not(without(ids)))` and `hasId(within(ids))` agree on membership and
+count. Numeric and string vertex IDs keep their distinct types.
+
+Groovy interpolated strings (`GString`, including subclasses) return as standard strings
+over GraphBinary, including inside lists and maps. For example, `def value = 42;
+"value:${value}"` returns `"value:42"`. The untyped GraphSON path retains the
+legacy GString bean map with its `values` and `strings` fields.
+
+Cypher extension predicates use the TinkerPop predicate constructor through the
+translator extension point. Computed-expression regex filters retain whole-string
+matching (`String.matches`), so `"marko"` matches `"mar.*"` and does not match `"ark"`.
+The public `cypherRegex`, `cypherIsString`, `cypherIsNode` and `cypherIsRelationship`
+factories imported by the bundled Gremlin configuration use that same adapter, so the
+Gremlin translation returned by `EXPLAIN` can be replayed.
+
+Cypher results normalize HugeGraph IDs, including values nested in collections, maps and
+paths. Cyclic results and nesting deeper than 32 levels are rejected.
+
+The default script engine is Gremlin Groovy. Validate application scripts against the
+full TinkerPop 3.5.1 to 3.8.1 upgrade interval before deploying.
+
+## YAML configuration safety
+
+Jackson JSON and YAML modules share the root BOM version. Fabric8 kubeconfig extensions
+can contain integer and decimal values; the K8s configuration regression verifies auto-
+configuration without contacting a cluster.
+
+The TinkerPop 3.8.1 runtime uses SnakeYAML 2.2, Spring Boot 2.5.15 and Spring Framework
+5.3.27. The Boot patch keeps PD and Store's YAML startup loader compatible with
+SnakeYAML 2.x. These versions belong to the upgraded runtime; do not apply the SnakeYAML
+override to a TinkerPop 3.5.1 deployment. The runtime also manages Jackson YAML 2.15.2
+so Fabric8 can read kubeconfig with SnakeYAML 2.2. Store's Log4j SLF4J binding and CLI
+logging use 2.18.0; Store Node retains its Boot-managed Log4j core version.
+
+Quota templates and PD-delivered Store configuration accept standard YAML maps, lists
+and scalars. Custom Java object tags such as `!!com.example.Type` are rejected before
+object construction. Keep these files as configuration data; Java class names used as
+ordinary string values remain supported.
+
+## List property compatibility
+
+The public TinkerPop `Graph.Features` contract changes:
+`features().vertex().properties().supportsUniformListValues()` and
+`features().edge().properties().supportsUniformListValues()` now return `false` instead
+of `true`. Clients and compatibility tests that inspect these flags may stop enabling
+uniform-list operations. The corresponding graph-variable flag remains `true`.
+
+HugeGraph still supports vertex and edge properties whose schema declares `LIST`
+cardinality with `valueList()` and a supported element type, for example
+`schema.propertyKey("tags").asText().valueList().create()`. Values must match the
+schema's element type. This schema capability does not provide arbitrary Java `List`
+values for `SINGLE` properties. The feature-reporting change itself does not remove
+stored lists or require rewriting existing list data.
+
+When upgrading, review client feature-detection branches. Applications using HugeGraph
+list properties should retain their explicit element type and `LIST` schema, and verify
+list writes and reads with that schema. Generic TinkerPop clients should honor the
+reported feature flags; do not treat a skipped uniform-list compatibility test as
+validation of HugeGraph's schema list path.
+
+## Query semantics
+
+### First query after startup
+
+Server initializes the condition-query serializer on its trusted startup thread before
+REST and Gremlin accept requests. A first label-filtered Gremlin query must succeed
+without a preceding REST graph query, and subsequent REST queries must remain usable.
+Gremlin scripts still cannot read restricted system properties such as `java.version`
+or disable the security manager.
+
+### Property-query index coverage
+
+For type-compatible values, a global property-index query such as
+`g.V().has("name", "alice")` or `g.E().has("weight", 0.5)` requires compatible
+indexes on every visible candidate label. A candidate declares all queried non-OLAP
+properties; even a label with no records requires coverage. Base edge labels have no
+direct records; their indexes can cover sub-labels. An explicit sub-label query
+also uses a compatible query-visible parent index when no compatible direct index
+exists, while still filtering out sibling sub-labels. Shared OLAP indexes follow the graph read
+mode.
+
+| Candidate state | Query behavior |
+| --- | --- |
+| Every visible candidate has compatible indexes | The query combines the indexed results. Composite and joint index plans may serve different labels in the same query. |
+| A visible candidate lacks a compatible index and the values pass the property-type check | The query fails with `NoIndexException`, including when other candidates are hidden or deleting. |
+| Every candidate is excluded by visibility | The query returns an empty result, including after those labels' indexes have been removed. |
+
+When query-visible candidates remain, index matching precedes the property-type
+check. If at least one index matches but a value fails that check, the query returns
+an empty result before complete coverage is checked. If no index matches, it still
+raises `NoIndexException`, even for a type-mismatched value.
+
+Normal queries exclude hidden labels and labels in `DELETING` or `UNDELETED`
+status. Internal requests that enable `showHidden` or `showDeleting` include the
+corresponding labels and still require their indexes. Visibility follows the outer
+request when a query is split into subqueries. Each branch checks index state and
+complete coverage before opening its backend iterators. If branch construction
+fails, the query closes cursors from both the unfinished branch and earlier
+branches, preserving the original error. Joint index planning closes unselected
+cursors and transfers the selected cursor to the result. This does not provide a
+snapshot against concurrent schema changes.
+
+To fix incomplete coverage, specify an indexed label, for example
+`g.V().hasLabel("person").has("name", "alice")`, or add indexes compatible with the
+query predicates to every candidate label. Explicit-ID lookups and adjacency
+filtering do not introduce a global property-index coverage requirement.
+
+### Predicate and value compatibility
+
+Version-specific handling retains negated predicates and filtering barriers where
+pushdown would change their meaning. For UUID IDs, local negated filters such as
+`hasId(P.not(P.neq(uuid)))` retain the same matches as ID equality. This also applies
+when an ID filter stays local beside a property filter that cannot be pushed down. The
+complete `HasStep` remains local; string and numeric ID comparisons keep their existing
+semantics. `P.typeOf()` runs as a local filter for system and schema properties,
+including inside negated or connective predicates. Its type operands are preserved
+without schema value conversion. Missing properties do not match, including when the
+type predicate is negated. Type filters do not use backend indexes on their own. The new
+TinkerPop step APIs are adapted without discarding property metadata.
+
+Named `GValue` predicates retain their bindings when reused or cloned, and
+`updateVariable()` applies when the predicate builds subsequent traversals. Explicit
+named `limit()`/`range()` bounds have the same count behavior as literal bounds. Local
+String ID comparisons retain their leaf semantics inside `not`, `and` and `or`
+predicates. Mixed ID collections retain the typed matches of strings, numbers and UUIDs
+when a filter stays local; all-string collections keep standard string-ID comparison
+behavior. Fractional numeric IDs are not truncated to integer IDs.
+
+Count filtering is only truncated when every predicate branch has a supported bound that
+preserves the filter result. Unsupported negated collection or custom predicate branches
+keep the complete count, including inside `and` or `or` conditions.
+
+DATE schema properties accept TinkerPop `OffsetDateTime` values for writes and indexed
+equality or range queries. Values normalize by instant to the existing `java.util.Date`
+millisecond representation; offsets do not change the stored instant, and sub-
+millisecond precision is truncated. Existing DATE data needs no migration.
+
+### Counts and transaction visibility
+
+Counts made inside a transaction include that transaction's uncommitted vertex and edge
+changes. Only `count()` supports this fallback; other aggregates with uncommitted
+changes are rejected. Queries that combine uncommitted changes with pagination, a limit,
+or an offset remain unsupported.
+
+Queries and counts select the current transaction version before applying filters. When
+an element is removed and re-added with the same ID, the new element shadows the old
+updated record, even if only the old record matches the query's label.
+
+A count query that enters an index query path remains unsupported when that index
+transaction has uncommitted changes. It reports `Can't do index query when there are
+changes in transaction`. This includes indexed-property conditions and label-only
+queries on backends that need a label index. Graph-wide and explicit-ID counts can still
+use transaction merging without entering that index path.
+
+Unsupported text predicates remain traversal filters. For ordinary GraphStep and
+VertexStep extraction, if any condition in a `HasStep` cannot be converted, the whole
+step remains local, including its sibling label, ID and property conditions. Count
+optimization preserves that filter step. Existing special handling around `match()` and
+connective label filters remains part of traversal optimization.
+
+A self-loop contributes two occurrences to a vertex's `bothE()` traversal and one to
+each of `outE()`, `inE()` and the graph-wide `E()` traversal. Counts retain these
+multiplicities before and after transaction commit, including edge updates.
+
+Count optimization preserves steps that can filter candidates. Resetting an optimized
+count traversal permits it to execute again. Query-step equality does not depend on the
+result iterator from a previous execution.
+
+A scan in the middle of a traversal is counted once per incoming traverser, including
+its bulk. It is not replaced by a single backend count. For example, with three
+vertices, `g.V().V().count()` returns `9L`, while `g.V().count()` returns `3L` and
+remains eligible for count optimization.
+
+### Read-only application checks
+
+Run these against an existing graph, replacing the label, keys and values with ones
+present in that graph. The queries do not write graph or schema data. Use property keys
+with the query indexes required by your backend. The `age` example assumes an integer
+property; the path example needs an existing outgoing edge.
+
+```groovy
+g.V().hasLabel('person').has('name', 'marko').count()
+g.V().hasLabel('person').has('age', P.gt(0)).order().by('age').range(0, 2).values('age').toList()
+g.V().has('name', 'marko').limit(1).out().limit(1).path().toList()
+g.V().has('name', 'marko').limit(1).id().toList()
+```
+
+The first result is a numeric count. The second is a list of at most two numeric values
+in ascending order. The third is a list of paths; untyped GraphSON represents each path
+with `labels` and `objects`. The fourth is a list of graph IDs encoded as scalar values
+by GraphSON or GraphBinary; the scalar type follows the graph's ID type.
+
+For Cypher, send this read-only query to `POST
+/graphspaces/{graphspace}/graphs/{graph}/cypher`, substituting existing graph data:
+
+```cypher
+MATCH (n:person) WHERE n.name = 'marko' RETURN id(n) AS nodeId
+```
+
+The response has rows under `result.data`; each row is a map and `nodeId` is a JSON
+scalar, not a HugeGraph internal ID class. The Cypher API test covers this shape, nested
+IDs and paths.
+
+## HTTP request errors
+
+The supplied WebSocket/HTTP channelizer releases each HTTP request buffer once,
+including when a traversal alias is unknown. Such a request returns one HTTP 400, and a
+later valid request on the same keep-alive connection receives its own response. This
+avoids TinkerPop 3.8.1's duplicate release and second HTTP 500, which could be mistaken
+for the next request's response by a connection pool. The correction applies when the
+configured channelizer is TinkerPop's standard `WsAndHttpChannelizer`; HugeGraph selects
+its compatibility implementation at startup and retains TinkerPop's WebSocket handling.
+Custom channelizer classes keep their configured behavior. Unknown aliases still require
+the caller to use a graph or traversal-source name exposed by the server.
+
+## Cypher HTTP contracts
+
+### Request forms
+
+The endpoint is `/graphspaces/{graphspace}/graphs/{graph}/cypher`.
+
+| Method and content type | Request | Behavior |
+| --- | --- | --- |
+| GET | `?cypher=` | Query-string form works |
+| POST `application/json` | Raw Cypher text | Legacy raw-body form works |
+| POST `text/plain` | Any body | Rejected with HTTP 415 |
+| POST `application/json` | JSON object below | Separate bindings work |
+
+```json
+{
+ "cypher": "MATCH (n:person) WHERE n.name = $name RETURN n.name",
+ "parameters": {"name": "marko"}
+}
+```
+
+POST bodies containing JSON arrays, strings, numbers, booleans, or null are rejected
+with HTTP 400. Malformed JSON objects and trailing tokens are also request errors and do
+not fall back to raw Cypher.
+
+For the JSON-object form, `cypher` must be a nonblank string and `parameters`, when
+present, must be an object. Omitting `parameters` means an empty map. Missing bindings
+produce an execution error; an explicit null value is accepted. Bindings may be named
+`id` and `label`. There is a fixed limit of 16 top-level parameters. This Cypher
+processor does not support a `maxParameters` configuration setting. Values containing
+quotes and newlines remain bound data rather than changing query text.
+
+A Map counts as one top-level parameter. Group related values into one Map and reference
+its fields explicitly; the vertex label and required properties must match the graph's
+existing schema:
+
+```json
+{
+ "cypher": "CREATE (n:person {name:$props.name, age:$props.age, city:$props.city}) RETURN n.name",
+ "parameters": {"props": {"name": "new-person", "age": 20, "city": "Beijing"}}
+}
+```
+
+Do not use the shorthand `CREATE (n:person $props)`: the pinned translator does not
+assign the bound Map's properties in that path. The parameter limit counts top-level
+bindings, not the entries inside a bound Map or a returned Map. A Map containing 17
+entries can be bound through one parameter with explicit property references; 17 top-level
+bindings are rejected.
+
+The translator's reserved null-marker string `" cypher.null"` (two leading spaces) is
+rejected in binding values, including nested maps and lists, to avoid silently
+converting a user string to null. Actual JSON null remains accepted. The same marker in
+query literals or stored properties is an existing translator limitation and is outside
+this compatibility target. Check the HTTP status and body `status.code`: execution
+failures retain HTTP 200 with `status.code` 400 and null result data.
+
+Traversal iteration failures, including Java errors, attempt rollback on the execution
+thread and return a terminal failure response. A rollback exception is attached to the
+original failure.
+
+### Compatibility coverage
+
+The API fixture uses a strong schema with a `SECONDARY` index on `city` and a `RANGE`
+index on `age`. The regression suite covers:
+
+| Area | Covered behavior |
+|---|---|
+| Reads | Label scans, equality and range predicates, computed regex whole-string matching, boolean combinations, directed one- and two-hop patterns, empty results. |
+| Results | Aliases, scalar and node values, nested maps/lists, relationship IDs, path shape, null and missing-property semantics. |
+| Aggregation and pagination | `DISTINCT`, `count`/`sum`/`min`/`max`/`avg`, `ORDER BY`, `SKIP`, and `LIMIT`. |
+| Parameters | String, number, boolean, null, empty and missing bindings; quote/newline preservation. |
+| Writes | Vertex and edge `CREATE`, property `SET`, edge and vertex `DELETE`, with native REST readback. |
+| Failures and routing | Invalid syntax, request shape, binding keys, schema values, authentication, and graph routing. |
+| Failed writes | A rejected multi-vertex create leaves no residue in later Cypher and native reads. |
+
+Write regressions check persisted state through native REST reads. Applications are not
+required to issue native reads after each successful Cypher write.
+
+### Scope and regression sources
+
+Advanced constructs including `MERGE`, `OPTIONAL MATCH`, `WITH`, `UNWIND`, `UNION`, and
+variable-length paths are not established by the compatibility coverage described here.
+HStore-specific behavior, Bolt, cross-request transactions, external client versions and
+performance also require separate validation. This guide does not claim full openCypher
+or Neo4j compatibility.
+
+The pinned translator is `org.opencypher.gremlin:translation:1.0.4`. Its translation
+behavior defines the limitations documented above.
+
+Long-term regression sources:
+
+- [`CypherApiTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/CypherApiTest.java)
+ covers request forms, schema-aware reads and writes, result shapes,
+ authentication and graph routing, and failed-write isolation.
+- [`CypherClientTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/api/cypher/CypherClientTest.java)
+ covers result normalization and HTTP client behavior.
+- [`CypherOpProcessorTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/opencypher/CypherOpProcessorTest.java)
+ covers binding validation and execution failure handling.
+
+## Transaction and graph ownership
+
+Server releases the current thread's graph transactions when a REST request finishes and
+when an authenticated context task exits. Cleanup visits every registered graph even
+when one graph fails to close, and reports failures. Cleanup explicitly rolls back
+unfinished writes, including when `onClose(COMMIT)` was configured, then releases
+backend transactions and resets thread-local transaction behavior and listeners.
+Applications must commit successful writes before leaving the request/task boundary.
+Closing an OLTP traverser preserves the caller's transaction so that the caller can
+still commit or roll it back.
+
+The cleanup also applies when schema caches have become cold. Deleting an auth project
+or user must remove its associated access or belong edges while preserving unrelated
+relationships. The special OLAP vertex path remains separate; negative schema IDs alone
+do not identify edges that can be skipped.
+
+Shared schema and element caches retain their invalidation listeners until the graph
+closes. Request cleanup releases backend leases while preserving those graph caches and
+their schema identity.
+
+Task database workers release their owned task and graph transactions on the worker
+thread. A failure while closing one owner must not skip cleanup of other owners or
+server-info resources. Cleanup retains the first exception and attaches later cleanup
+failures as suppressed exceptions.
+For the local Standard scheduler, a deleted task disappears from public lookup after its persisted record is
+removed, while its actual execution still counts toward drain until native cleanup finishes; late completion saves from
+that owner do not recreate the removed record.
+
+Synchronous task cancellation preserves a transaction already open on its caller,
+including uncommitted graph writes. Cancellation still runs the normal completion and
+status callbacks, but their default cleanup does not close that existing transaction.
+The scheduler closes transactions opened by a cancellation callback when it had no
+transaction before starting, and a running task retains its worker owner until that
+worker's own cleanup finishes. The caller remains responsible for committing or rolling
+back its original transaction.
+
+Graph close attempts auth, scheduler and caller-transaction cleanup separately. If a
+scheduler remains registered or the graph reports active transactions, the graph remains
+open for a later close attempt. Pending local tasks retain their task database transaction
+so they can persist completion before the next close attempt. An incomplete scheduler
+drain does not queue transaction cleanup behind that scheduler's blocked worker. Stopping
+distributed task dispatch is distinct from completing drain: a timeout does not make
+the next close return success.
+
+During Server shutdown, the task manager waits for task workers to finish, then retries
+closing graphs retained in its existing scheduler registry before stopping the task
+database worker. This gives pending tasks time to persist completion and releases the
+task database owner's transactions on that owner's thread. Each retained graph gets one
+close attempt per shutdown call. A close failure remains visible and keeps the database
+executor available for a later explicit retry; it does not start background recovery.
+A terminated cron executor already satisfies the scheduler's dispatch barrier. Every
+executor wait, including an explicit retry after a timeout, checks actual termination;
+shutdown requested alone is insufficient. Store-close notifications finish before the
+shared event executor is stopped.
+
+Once these logical checks pass, graph close releases shared cache listeners, invokes
+`provider.close()` and destroys graph locks even if an earlier cleanup step reported an error. The error
+remains visible to the caller. Logical graph closure does not prove that a failed
+backend native-resource release succeeded; callers must inspect the reported failure
+rather than infer physical release from graph state.
+
+Regression sources:
+
+- [`TransactionLifecycleTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/auth/TransactionLifecycleTest.java)
+ exercises request and authenticated-task ownership and rollback.
+- [`StandardTaskSchedulerTxTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/task/StandardTaskSchedulerTxTest.java)
+ exercises task worker transaction cleanup.
+- [`HugeFactoryTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/HugeFactoryTest.java)
+ exercises graph close failure continuation, distributed drain retry and Server stop
+ with a task that outlives the early graph-close attempts.
+
+## Compatibility evidence and deployment checks
+
+Checked-in regression tests define a coverage boundary, not a supported matrix for every
+external client, authentication method or backend deployment.
+
+| Path | Regression coverage | Deployment checks still required |
+|---|---|---|
+| GraphSON | Serializer configuration, MIME selection and wire fixtures. | External client versions, network and authentication combinations. |
+| GraphBinary | Shipped Driver configuration, typed results and in-process ID, predicate and element round trips. | External client, network and authentication combinations. |
+| Cypher HTTP | Local API harness, result IDs and paths, Basic-auth test client. | Bearer authentication and external REST clients. |
+
+[`GremlinConfigCompatibilityTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/config/GremlinConfigCompatibilityTest.java)
+contains serializer and configuration regressions. Use the Cypher sources linked above
+for API behavior. Keep runtime dependency versions aligned with the root POM and shipped
+configuration rather than copying old configuration fragments into a different runtime.
+
+Structure and Process suites exercise TinkerPop behavior on Memory, RocksDB and HStore.
+HStore has separate standard-process and feature-test selections.
+[`HugeGraphProviderLifecycleTest`](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/tinkerpop/HugeGraphProviderLifecycleTest.java)
+checks backend cleanup and graph reopening. The test selections and setup are
+maintained in the [test POM](../hugegraph-server/hugegraph-test/pom.xml),
+[Server CI](../.github/workflows/server-ci.yml) and
+[PD/Store CI](../.github/workflows/pd-store-ci.yml).
+
+Inspect executed tests for the backend and protocols used by the deployment. Selected
+Structure or Process suites must execute tests; an all-skipped report is not
+compatibility evidence. Run the read-only application examples against representative
+existing data and validate the serializers, client versions and authentication
+combinations actually used by the application.
+
+## Cache RPC graph lifecycle
+
+When cache RPC is enabled and a backend declares shared storage, Server registers both
+graph and schema cache notifiers for graphs loaded during startup and graphs created or
+loaded later. A new graph's RPC registration must succeed before its local publication.
+Graph services use the graph space and graph name together as their service identity.
+PD deletion and startup coordinate RPC removal for the same graph instance. Startup does not
+register an owner whose RPC removal is pending, even after its placement-event token is consumed,
+and checks logical closure under the actual owner's RPC lifecycle monitor before registration.
+Any retained provider configuration must be unregistered before startup can export services;
+a cleanup failure fails startup instead of re-exporting that owner. Graph clearing, task drains
+and full graph close remain outside this RPC publication gate.
+
+Internal graph loading registers RPC services in Server's administrative scope and
+restores the caller's identity afterward. Direct registration and cleanup remain
+administrative operations.
+
+Successful graph close releases that graph's notifier listeners, providers and consumer
+proxies. A failed task drain during ordinary close or non-clearing local removal retains
+the owner and its RPC resources. In PD-enabled deployments, RPC cleanup failures during
+graph DELETE retain the local owner and bindings for retry; partial cleanup does not imply
+a healthy service. This DELETE path first closes task admission and drains local executions,
+then cleans up RPC before metadata deletion and backend clearing. A failed drain or RPC
+cleanup can be retried with the same DELETE. Logical graph closure fences access before
+all resource cleanup completes. Any close failure retains the local owner and bindings;
+retrying the same DELETE retries cleanup without repeating backend clearing. A completed clear
+is remembered for that exact graph instance even if an external idle transaction delays close;
+retry after that transaction exits. A replacement graph with the same name is cleared independently.
+A graph closed by a non-clearing removal has no successful-clear certificate. A later clearing DELETE
+refuses that closed owner and keeps its binding, rather than reporting success while old backend data remains.
+Raft provider, node and an owned listener are closed independently. The first fatal Java Error takes
+priority over ordinary close failures; otherwise the first failure is retained. Other failures are
+reported as suppressed, with a repeated reference to the primary failure ignored.
+A graph using Server's shared Raft listener closes
+only its own node. Server releases that listener and its address registration on shutdown;
+deleting one graph keeps the listener available to other local graphs. An interrupted node
+join retains the graph owner and interrupt status so a later DELETE can finish cleanup.
+The context's backend, snapshot and optional read-index executors stop after node join and
+drain queued work before close completes. Their wait uses `task.wait_timeout`; timeout or
+interruption retains the graph for retry without cancelling queued operations.
+RPC cleanup can start before graph close finishes. Clearing PD DELETE unregisters RPC before
+metadata removal and backend clear. Factory, binding and quota removal require a successful graph close;
+a failed close retains their owner even if its RPC services have already been unregistered.
+One failing graph does not prevent other graphs in a PD removal batch from being processed.
+Fatal Java Errors retain their original identity and
+propagate out of the removal batch. Failures after backend clearing do not undo deletion;
+retries do not restore deleted data or placement metadata.
+
+For local deletion without PD, backend clearing precedes RPC cleanup during graph close.
+A close failure retains the same local graph, factory identity and configuration for DELETE retry,
+even when access has already been fenced by logical closure. Only a successful clear by that
+instance's local drop permits retry to skip clearing and finish its retained resource cleanup.
+An ordinary close does not provide that certificate. A failure before clearing keeps the open
+owner's existing RPC services. A later cleanup failure does not preserve or restore deleted data.
+
+Failed creation or local loading closes the candidate in Server's administrative scope.
+If rollback close fails, Server retains the local owner and factory identity for cleanup retry.
+Retained failed candidates are excluded from RPC startup even while they are still open.
+An old graph's cleanup cannot remove or re-export a replacement graph's services.
+A PD graph-add notification blocked by an existing owner is coalesced until that owner is
+released, including when independent watches deliver the new ADD before the old REMOVE.
+Duplicate ADD notifications alone keep the healthy owner. Server consumes the PD acknowledgement
+of its own local graph creation without retaining a replacement ADD unless that exact owner already
+has failed peer cleanup; the existing cleanup retry takes priority. A healthy creation acknowledgement
+cannot republish the graph during a later reload. After removal, Server rereads the latest configuration;
+a missing configuration creates nothing. Reload consumes pending ADDs only after each old owner is
+successfully removed, then loads current metadata together instead of replaying ADDs during removal.
+After a peer removal fails, a later same-name PD add retries non-clearing cleanup of that exact
+owner before loading its replacement. Another cleanup failure keeps the owner for the next add
+or normal reload; no background retry is scheduled. A replacement load failure is logged and
+remains pending for a later add or normal reload. The completed old-owner DELETE still succeeds.
+Events queued for a closed notifier are ignored; an RPC request already sent cannot be recalled.
+
+Cache RPC uses the configured peer addresses; local provider export does not establish
+that every remote consumer connection is ready. Notifications are asynchronous, and
+connection or delivery failures are logged. This lifecycle handling does not provide
+acknowledged delivery or a distributed cache consistency protocol. Validate actual DML
+and cache behavior against the deployment's shared backend and topology.
+
+## Deleting a graph with local tasks
+
+Stop application writes before deletion and keep them stopped during retries. The local
+scheduler closes task admission and waits for actual executions, cancellation callbacks
+and their transaction cleanup before data or metadata is removed. If draining times out,
+the graph stays registered with its data and configuration, but task admission remains
+closed: new asynchronous tasks are rejected even after the existing owners exit. Keep
+application writes stopped and retry deletion after those owners exit. If no destructive
+cleanup has begun, restart the Server to resume task admission instead of completing
+the deletion. Closing the scheduler alone leaves unstarted persistent work in storage.
+
+This is a local shutdown guarantee. For a graph shared by several Servers, stop writes
+and tasks on every Server before deletion; notification delivery is not a cluster-wide
+barrier. Do not recreate the same graph name until deletion finishes. Use a restart for
+configuration changes rather than hot reload, whose recovery is handled separately.
+Deletion is not transactional: a failure after destructive cleanup needs operator
+inspection, and retry does not restore removed data. An owner-cleanup failure must not
+be treated as a successful drain; inspect it instead of force-removing the scheduler.
+
+## Local loading ownership
+
+Server keeps ordinary import ownership in memory on each graph instance. The first
+owner changes NONE to LOADING; releasing one owner leaves LOADING while others are
+active, and the last release restores NONE. Manual LOADING, RESTORING and MERGING
+cannot be adopted. Original enum setters reject every request while owners are active,
+including bare LOADING; their original behavior remains available without active owners.
+
+`GET /graphspaces/{space}/graphs/{graph}/mode/owners` returns
+`{"protocol":1,"instance":"","epoch":0,"revision":0}`. This response is illustrative;
+recovery must use the actual values returned by the latest GET. `PUT` on that path accepts
+`{"action":"acquire","instance":"","epoch":0,"token":""}`;
+`release` uses the same tuple. Receipts echo protocol/instance/epoch/token and return
+`state` (`active` or `released`) and the effective `mode`. Both operations use existing
+graph-owner/STATUS WRITE permissions; the authenticated principal owns the token.
+Each live graph admits at most 1024 concurrent owners. At that limit, a new owner
+receives HTTP 409 without changing state; existing-owner retries, release and
+administrator recovery remain available. Releasing an owner frees a slot.
+The request principal is also bound for graphs opened without the authentication proxy.
+Each new acquire, actual release and successful recovery writes an INFO audit entry
+with the graph name, graph instance, previous/resulting mode, epoch and owner counts. Idempotent
+retries do not add entries. These audit entries omit tokens and authenticated principal values.
+
+Keep one tuple and identity for the whole import attempt, including uncertain acquire
+outcomes. Release after that import's workers stop. Acquire/release retries are
+idempotent. An unknown release cancels a late acquire; a released handle cannot be
+reused. Terminal records are bounded to 1024 per epoch. Epoch advancement retains
+active old handles and permanently retires all other old handles. An initial
+acquire prepared before epoch advancement can therefore be rejected even if it
+never became active. The client must not automatically refresh that retired handle:
+a new token could undo a prior cancellation or administrator recovery. After a
+terminal acquire rejection, stop and drain that import attempt before creating a
+fresh Loader context to retry.
+
+This protection coordinates Loader processes connected directly to the same stable
+Server and graph instance. It does not fence ordinary data requests across load
+balancers, node failover, graph replacement or Server restart. There is no automatic
+expiry: after kill -9 or permanent disconnection, stop the import and its in-flight
+writes before retrying release with the same handle and identity. If that handle is
+lost, an administrator can recover without that token:
+
+1. Stop every related import and drain its in-flight writes. Keep ordinary writers
+ stopped too; the owner protocol does not fence graph data requests.
+2. Read the current instance, epoch and revision from the owners endpoint on the same Server.
+3. Send `PUT` to that endpoint with
+ `{"action":"recover","instance":"","epoch":0,"revision":0,"confirm":"I'm sure all imports have stopped"}`.
+
+Recovery requires the authenticated global administrator role even when graph-space
+permission checks are disabled; a proxied graph also enforces administrator permission.
+With Server authentication disabled, the existing trusted anonymous administrator semantics apply.
+Recovery also requires the exact current instance/epoch/revision and explicit confirmation. It clears owners, advances the epoch and returns NONE;
+late acquire/release requests from the retired epoch cannot register an owner again.
+The receipt contains the new epoch and `state: recovered`, without tokens. A stale
+recovery request returns a conflict and cannot clear an import started in a newer
+epoch. Owner registration/release and every successful enum mode write advance the
+revision, including a write of the current mode. Either operation after inspection
+therefore rejects the stale recovery request even within the same epoch. After an uncertain result, inspect current state rather than blindly
+retrying with a newly fetched epoch/revision. This operation does not stop or drain imports itself.
+
+The same recovery procedure can reset manual RESTORING or MERGING after that work
+has stopped and drained; restore completion keeps its historical RESTORING mode.
+Before ordinary import on an owner-capable Server, explicitly recover or set NONE
+only after inspecting the completed work and confirming there are no active imports.
+Expected loading-owner and manual-mode conflicts return HTTP 409; invalid requests
+return 400 and unauthorized recovery is rejected by the authentication layer.
+
+Loaders retain historical ordinary-import and restore behavior with Servers that do
+not provide this API. Concurrent owner protection applies only when this API is
+supported; RESTORING keeps its existing enum mode path.
diff --git a/docs/shared-foundation-migration.md b/docs/shared-foundation-migration.md
new file mode 100644
index 0000000000..b7221f5b9b
--- /dev/null
+++ b/docs/shared-foundation-migration.md
@@ -0,0 +1,267 @@
+# Shared foundations: preparing for the 1.8.0 migration
+
+The planned 1.8.0 consolidation changes Java imports and extension contracts, and requires a coordinated Server, PD and Store upgrade. Use this guide to identify affected code, check historical-data compatibility and prepare your deployment.
+
+Shared implementations move out of Server core so Server and Store can use the same types and codecs. This guide covers the planned 1.8.0 migration.
+
+## What do I need to change?
+
+| If you maintain… | Your next step |
+|------------------|----------------|
+| A REST client, Loader integration or client DTO | Check actual affected Java calls; REST use alone does not require replacing client DTOs with struct types. |
+| Java code importing Server IDs, schema, queries or indexes | Update the imports and affected signatures below, then recompile against matching 1.8.0 artifacts. |
+| A `HugeGraph` implementation, serializer or element subclass | Adapt the API/SPI contracts below; old compiled implementations are not binary-compatible. |
+| Custom Gremlin scripts or `classImports` | Change the `IdGenerator` class name and verify it in the packaged Gremlin service. |
+| Hubble, Toolchain or Computer | Apply the downstream exception/classpath changes and validate the built jars. Use matching downstream artifacts and check their packaged classpaths. |
+| An HStore installation | Check OLAP writes, Store-rebuilt indexes and metadata namespaces before a coordinated upgrade. |
+| PD or Store code | Use the shared owners without importing Server core; keep service-specific execution and resource lifecycle local. |
+
+## Why share these implementations?
+
+Before this change, Server core and struct carry separate implementations of several IDs, schema types, queries, elements and codecs. A fix to one copy can leave the other with different behavior. Consolidating them gives those fixes one owner and gives Server and Store a common implementation to test.
+
+```text
+Before: Core and Struct each maintain shared types and codecs.
+
+After:
+Server core --> Struct --> Common
+Store core --> Struct
+PD service -------------> Common
+```
+
+*Shared-foundation ownership, rather than the complete Maven dependency tree.* Server still needs PD/Store clients for HStore; Store still needs its network, PD client and storage libraries. Struct retains its shared index/analyzer and codec dependencies, including shaded Kryo where required.
+
+| Capability | Owner after migration | What remains with the caller |
+|------------|-----------------------|------------------------------|
+| IDs, schema metadata and type codes | struct | Import and use the shared types |
+| Queries, base elements, byte/property codecs | struct | Adapt graph-engine and backend behavior |
+| Index construction, analyzers and OLAP selection | struct | Supply schema candidates and result sinks; preserve configured analysis and index results |
+| Transactions, traversal, tasks and schema mutation | Server core | Operate on shared metadata and elements |
+| PD-backed schema access | Store | Manage `SchemaGraph`/`SchemaDriver`, listeners and cache lifecycle |
+| JWT signing/verification and auth constants | common | Supply configuration and translate failures into service responses |
+| RPC client/server configuration interfaces | common | Keep existing implementations and configuration values |
+
+`HugeGraphSupplier` lets shared code access schema, configuration and the clock without depending on the graph engine. Core and Store implement it.
+
+Core elements wrap shared base elements: property mutation, cloning, removal, expiration and loading state use that same state. `BaseVertex` owns adjacency; the engine caches edge wrappers by identity over the same base edges. Core serializers retain their engine/backend adapters and delegate shared encoding. Core also retains transaction writes, backend capability checks and index-update orchestration around shared index construction and OLAP selection.
+
+## Migrate Java code and extensions
+
+### Start with the imports
+
+For example, code that creates an ID changes its imports while keeping the factory call:
+
+**Before**
+
+```java
+import org.apache.hugegraph.backend.id.Id;
+import org.apache.hugegraph.backend.id.IdGenerator;
+
+Id id = IdGenerator.of("alice");
+```
+
+**After**
+
+```java
+import org.apache.hugegraph.id.Id;
+import org.apache.hugegraph.id.IdGenerator;
+
+Id id = IdGenerator.of("alice");
+```
+
+| Previous entry | Shared entry or migration |
+|----------------|---------------------------|
+| Shared types in `org.apache.hugegraph.backend.id` | `org.apache.hugegraph.id.*` |
+| `org.apache.hugegraph.schema.*` metadata | `org.apache.hugegraph.struct.schema.*` |
+| Shared types in `org.apache.hugegraph.backend.query` | `org.apache.hugegraph.query.*` |
+| `org.apache.hugegraph.backend.query.serializer.*` | `org.apache.hugegraph.query.serializer.*` |
+| `org.apache.hugegraph.backend.store.Shard` | `org.apache.hugegraph.backend.Shard` |
+| `org.apache.hugegraph.backend.store.BackendEntry.BackendColumn` | `org.apache.hugegraph.backend.BackendColumn` |
+| `org.apache.hugegraph.structure.HugeIndex` | `org.apache.hugegraph.structure.Index` |
+| Shared bytes/encoding in backend serializers | `org.apache.hugegraph.serializer.*` |
+| Core `HugeException` | `org.apache.hugegraph.exception.HugeException` |
+| `org.apache.hugegraph.SchemaGraph` / `SchemaDriver` | `org.apache.hugegraph.store.schema.*` |
+
+Keep the core imports for `SnowflakeIdGenerator` and the query execution helpers
+`QueryResults`, `ConditionQueryFlatten`, `EdgesQueryIterator`, `QueryBatch` and `QueryResultContext`.
+The table applies to shared types, not entire packages. `SchemaManager`, schema mutation builders and backend-specific serializers remain in core. There is no general compatibility package for removed core classes. Relocated types also change method descriptors that expose IDs, schema, queries and indexes, so update implementations and call sites and recompile every affected integration against matching artifacts. Changing source imports does not make old binaries compatible.
+
+### Resolve conditions through the shared query type
+
+Server and Store now use `org.apache.hugegraph.query.ConditionQuery` from struct.
+The explicit resolution methods operate on top-level `EQ` and `IN` relations
+for a key and intersect their candidate values. Nested conditions and other
+operators remain outside this resolution.
+
+| Method | Result |
+|--------|--------|
+| `containsCondition(HugeKeys)` | Reports whether any top-level relation exists for the system key. |
+| `containsConditionValues(key)` | Reports whether a top-level `EQ` or `IN` relation exists, including an empty `IN`. |
+| `conditionValues(key)` | Returns the candidate intersection. Pair it with `containsConditionValues` to distinguish absence from an empty intersection. |
+| `conditionValue(key)` | Returns `null` for an empty intersection, the value for a singleton, and throws for multiple candidates. |
+| `singleConditionValueOrNull(key)` | Returns the value for a singleton and `null` for every other candidate count. |
+
+The existing `condition(key)` method keeps its legacy behavior, including
+returning the original list for a sole `IN` relation. Use strict
+`conditionValue` when a serializer requires one value; use
+`singleConditionValueOrNull` when an optimization applies only to one candidate.
+For example, `IN [1, 2]` combined with `EQ 2` resolves to `2`, while an
+additional `EQ 3` produces an empty intersection.
+
+SEARCH index construction and local SEARCH matching share the analyzer and
+term handling in struct's `IndexBuilder`. Engine callers use
+`HugeGraph.searchPredicate(text)` so the graph selects its configured analyzer
+and the authorization proxy checks access before delegation.
+
+### Check API and SPI implementations
+
+| Integration point | Required change or preserved contract |
+|-------------------|---------------------------------------|
+| `HugeGraph.sameAs` | Change `sameAs(HugeGraph)` to `sameAs(HugeGraphSupplier)`. External overrides must accept the supplier, as `StandardHugeGraph` and `HugeGraphAuthProxy` now do. There is no old-signature overload. |
+| Custom `HugeElement` subclasses | Provide shared `BaseElement` state through `element()` and adapt `BaseProperty` through `wrapProperty`. Removed protected engine state fields and a second adjacency store must not be restored. |
+| `GraphSerializer.writeIndex` / `readIndex` | Accept/return `org.apache.hugegraph.structure.Index`; update SPI implementations and callers. No long-lived `HugeIndex` wrapper is retained. Hash-ID generation belongs to shared `Index`. |
+| `RpcServiceConfig4Client` / `RpcServiceConfig4Server` | Both now belong to common, with the same `org.apache.hugegraph.rpc` names and signatures. Existing RPC implementations and values need no source migration. Duplicate core/commons-rpc definitions are removed without adding a dependency edge. |
+| `TokenGenerator` | Use common's `org.apache.hugegraph.auth.TokenGenerator`; supply the signing secret and translate JWT failures locally. Common's JWT dependencies are optional, so direct JWT consumers must declare their required JWT libraries. |
+| Engine vertices and edges | `HugeVertex` and `HugeEdge` remain engine adapters; Store uses base elements. |
+
+### Update downstream exceptions without changing their meaning
+
+Distinct downstream exception contracts need distinct class names on the shared classpath. Use these replacements with the matching Toolchain artifacts:
+
+| Previous downstream entry | Replacement and contract |
+|---------------------------|--------------------------|
+| Hubble `org.apache.hugegraph.exception.HugeException` | `org.apache.hugegraph.exception.HubbleException`: still a `RuntimeException` with its string constructor |
+| Client `org.apache.hugegraph.exception.NotSupportException` | `org.apache.hugegraph.exception.ClientNotSupportException`: still a `ClientException`, with existing message/cause and message/arguments constructors |
+| Hubble's copy of `org.apache.hugegraph.license.MachineInfo` | Keep the same import; use the implementation supplied by `hugegraph-common` |
+
+Update imports, constructor calls and handlers for these exceptions. Struct's `HugeException` and `NotSupportException` have different contracts and are not substitutes for those catches. Computer's `HgkvDirImpl` must also use `ClientNotSupportException`.
+
+Rebuild the affected Toolchain and Computer integrations and check their packaged classpaths before deployment. A source scan for duplicate names cannot rule out stale or transitive classes in built jars. Loader's REST boundary and independent client DTOs do not need wholesale conversion to struct.
+
+### Check configuration-only class names
+
+Replace `org.apache.hugegraph.backend.id.IdGenerator` with `org.apache.hugegraph.id.IdGenerator` in Gremlin `classImports` and scripts. The bundled default and Raft examples use the shared class. These strings are not checked by Java compilation, so test them in the packaged Gremlin service.
+
+## Understand data and runtime compatibility
+
+The migration preserves existing type codes, ID/property bytes, ordinary Server index keys, TTL formats, query semantics and configuration defaults. It introduces no general data-rewrite operation. The following cases need separate attention:
+
+| Area | Behavior after migration | Action or limit |
+|------|--------------------------|-----------------|
+| String ID comparison | Uses Java UTF-16 string order even before an ID loaded from UTF-8 bytes is decoded | Persisted bytes stay the same; supplementary Unicode characters no longer compare differently after lazy decoding. |
+| Property values | Preserves schema-driven persisted rows and tagged Store shuffle/aggregation values | Readers choose the mode from the known producer/request plan, not by guessing from bytes. |
+| SYSTEM-label index expiry | Preserves Store's stable key and 13-byte value envelope for positive expiry, and core's existing SYSTEM-label bytes | The bounded legacy envelope is read at the serializer boundary; no key TTL suffix or prefix-deletion change. |
+| Store-rebuilt long-text indexes | Uses the complete value and hash expected by Server queries | Assess old Store rebuilds; shortened historical rows may require rebuilding from graph data. |
+| HStore OLAP physical keys | New writes use `[property ID][vertex ID]` and readers retain a matching legacy-row fallback | Upgrade writers together. Previously overwritten properties cannot be recovered. |
+| Schema map decoding | Retains ordered primary/sort keys and resolves endpoint metadata independent of map field order | Matching or legacy endpoint forms work; conflicting or malformed metadata is rejected. |
+| Metadata namespace | Store uses `pd.cluster` (default `hg`) across schema, configuration, watches and TTL-cleaner metadata | Match the Server/graph namespace and preserve backend graph names. |
+| Historical serialized Java names | Query/Kryo readers retain required legacy names and aliases | Type relocation does not authorize a new wire format. |
+
+### Store-rebuilt indexes
+
+The old Store `IndexBuilder` shortened long text to 20 characters, while Server queries used the complete value and its hash. The shared builder adopts the Server contract. Frozen producer/query fixtures describe that existing mismatch in the [compatibility README](../hugegraph-struct/src/test/resources/compatibility/2f827d6e8/README.md#existing-indexbuilderquery-mismatch).
+
+Old shortened rows remain decodable, but do not automatically match a query using the full value. Check whether your installation used Store-side index rebuilding and rebuild affected indexes from graph data where necessary. The migration neither rebuilds them automatically nor recovers missing historical entries. Ordinary Server-created index keys keep their existing contract.
+
+### HStore OLAP writes
+
+A vertex-only physical key allowed one OLAP property to overwrite another on the same vertex. The new `[property ID][vertex ID]` key lets those properties coexist. This is a behavior repair beyond package relocation; existing row values remain readable without rewriting them.
+
+Readers try the requested property's compound key first, then the legacy vertex-only key if its value contains the matching property ID. Deleting one property removes its compound row and a matching legacy row while preserving other properties. Values already overwritten by the old writer are lost.
+
+Clearing an OLAP property deletes its matching compound and legacy rows while
+retaining its schema. Removing the property deletes those rows before the existing
+schema/index removal job completes. Both operations retain the shared OLAP table,
+other properties and other graphs. Cleanup scans the shared table and commits
+bounded batches; if a batch fails, the task fails and the pending session is rolled
+back. Deletions already committed to Store nodes can remain, so retrying cleanup
+is idempotent; the operation does not promise graph-wide atomicity.
+
+Quiesce writes to the affected OLAP property before clearing or removing it, and
+keep those writes stopped until cleanup completes. The scan and bounded delete
+batches do not coordinate concurrent writers: a new row can be missed, and a
+rewrite of a scanned row can be deleted. An inconsistent compound key/value
+blocks cleanup until the affected row is repaired; the error includes its key
+in hexadecimal, limited to the first 64 bytes.
+
+Mixed-version writes can produce stale reads: an old writer may update a legacy row while a new reader prefers an earlier compound row. Upgrade all Server and Store writers before resuming writes.
+
+### Match metadata namespaces
+
+In Store `application.yml`, the existing namespace setting is:
+
+```yaml
+pd:
+ cluster: hg
+```
+
+The environment equivalent is `PD_CLUSTER`. Match Server's `cluster` when `usePD=true`.
+With `usePD=false`, the first HStore graph opened binds the process-wide `MetaManager` to its `pd.cluster`;
+a later graph's explicit conflicting value is logged and ignored. Configure all HStore graphs in the
+process with the same namespace. Store rejects reuse of its schema driver under a conflicting namespace.
+
+Keep backend graph-name components such as `DEFAULT/hugegraph/g` (`graphspace/store/table`). The REST identity `DEFAULT-hugegraph` is not a metadata key.
+
+## Plan a coordinated upgrade
+
+```text
+Prepare --> Adapt Java code --> Upgrade matching services --> Verify
+
+HStore OLAP keys:
+Before: vertex ID
+After: [property ID][vertex ID]
+```
+
+*Use matching Server, PD and Store artifacts; mixed-version rolling upgrades are outside this migration's supported upgrade contract.*
+
+1. **Prepare integrations.** Update Java APIs/SPIs and Gremlin class names, recompile affected callers and check downstream delivery. Assess Store-rebuilt long-text indexes and OLAP usage.
+2. **Back up data and configuration.** Use the existing deployment procedure. Record metadata namespaces and backend graph names so they remain consistent.
+3. **Stop writes and upgrade together.** Install the matching Server, PD and Store release artifacts. Upgrade all OLAP writers before allowing writes again.
+4. **Verify the built deployment.** Start the packaged services, check Gremlin imports, authentication, schema access and relevant RocksDB/HStore paths. Validate historical data and the built classpath; compilation alone is not sufficient.
+5. **Rebuild affected indexes and resume writes.** Where old Store rebuilds produced shortened text keys, rebuild from graph data as needed and verify full-value queries. Check OLAP properties and metadata routing before resuming.
+
+Validate the packaged deployment and affected integrations before resuming writes, using the compatibility checks below.
+
+## Compatibility reference for implementers
+
+### Codecs and schema
+
+Persisted property rows and transient tagged results share the scalar codec. Both must retain their historical samples. Ordinary schema index writers keep the Server key/name-TTL layout. `Index.hasTtl()` follows the engine's existing system-index contract; Store's expiry envelope stays at the serializer boundary. Unsupported index-only element reconstruction remains unsupported.
+
+Shared `Shard` preserves its GraphSON tag and fields, and query decoding retains its legacy fully qualified name. Toolchain's `org.apache.hugegraph.structure.graph.Shard` stays a separate REST DTO. `BackendColumn` retains mutable `name`/`value` byte arrays. Its hash now uses array contents consistently with equality instead of core's array-identity hash. Historical Kryo names require decoder aliases and fixture checks; the fields retain their bytes. Store's `RocksDBSession.BackendColumn` stays a backend adapter.
+
+Schema maps preserve wire field names and ID identity. Two decoder fixes accompany relocation: primary-key and edge sort-key lists retain order and duplicates rather than passing through a set, and redundant edge endpoints are resolved consistently regardless of map field order. Matching `links` and source/target fields are accepted; legacy source/target-only maps remain readable; conflicting or malformed endpoints are rejected. Ordered-schema and endpoint-map tests cover generated vertex/edge IDs and query values, but do not establish all distributed or packaged release gates.
+
+Query JSON still uses Java names for some relation values. Decoder mappings must read historical names, and historical values still require Kryo decoding where applicable. Replacing these Java names with value tags would require a separate wire-format compatibility policy.
+
+### System-vertex classification
+
+`BaseVertex.TypeContext` makes the existing engine/storage distinction explicit:
+
+| Context | `~variables` | `~server` / `~role_data` | `~task` / `~taskresult` |
+|---------|--------------|------------------------|------------------------|
+| `STORAGE` (standalone storage elements) | Task data | Ordinary vertex data | Task data |
+| `ENGINE` (engine wrappers) | Ordinary vertex data | Server data | Task data |
+
+Engine wrappers must select `ENGINE`; standalone storage elements retain `STORAGE`. Selecting the correct context preserves existing table routing and readers for system vertices.
+
+### Review and release checks
+
+Review behavior alongside the package moves. The key entrypoints and invariants are:
+
+| Area | Main entrypoints | What to verify |
+|------|------------------|----------------|
+| Java API/SPI | `HugeGraphSupplier`, `HugeGraph.sameAs`, `GraphSerializer`, custom `HugeElement` | Caller recompilation and the documented migration |
+| Element state | `BaseElement/BaseVertex/BaseEdge`, `HugeElement/HugeVertex/HugeEdge`, offheap properties | One state owner, callback order, clone/adjacency identity and virtual value reads |
+| Historical codecs | `BytesBuffer`, `BinaryElementSerializer`, `BinarySerializer`, `LegacyClassNames`, `KryoUtil` | Producer-specific bytes and old enum ordinals, with canonical writers preserved |
+| Queries, schema and indexes | `ConditionQuery`, `EdgeLabel.fromMap`, `IndexBuilder`, `Index` | Query behavior, ordered keys, endpoints, full-value hashes and TTL contracts |
+| HStore and metadata | `HstoreTables`, `OlapStage`, `SchemaDriver`, `FilterIterator` | OLAP repair, exact owner reads, batch bounds, namespaces and watch closure |
+| Delivery | Shared-foundation/inventory guards, packaged jars, Gremlin imports, downstream callers | Resolved/shipped dependencies, startup and paired documentation |
+
+Release evidence must include shared tests and frozen pre-migration byte/JSON fixtures for IDs, schema, properties, indexes, pagination, TTL/OLAP and supported Kryo data. It must also cover RocksDB/HStore execution, Store filtering and schema access, authentication, element state propagation and affected downstream tests.
+
+Check resolved dependencies and built distributions: PD/Store must not depend on Server core, struct must not depend on PD client/core, and packaged jars must not contain duplicate HugeGraph classes. Start the packaged services without a classpath-ordering workaround. Independent review and CI guards must prevent new duplicate owners or forbidden dependencies.
+
+Build/test commands and service prerequisites are in [BUILDING](BUILDING.md), module guidance and CI. Ensure tests actually run; `-DskipTests` is not compatibility evidence.
+
+Deploy matching Server, PD, Store and downstream artifacts. Validate both the Java integrations and the packaged services before adopting the migration.
diff --git a/docs/storage-lifecycle.md b/docs/storage-lifecycle.md
new file mode 100644
index 0000000000..4f4eed5b0d
--- /dev/null
+++ b/docs/storage-lifecycle.md
@@ -0,0 +1,625 @@
+# Storage lifecycle, recovery and runtime compatibility
+
+This guide covers Store RPC ownership and shutdown coordination, standalone RocksDB
+snapshot recovery, and storage runtime upgrades. Use it when changing request cleanup,
+deploying storage components, recovering an interrupted restore, or upgrading JNI.
+
+Store RPC lifecycle and standalone RocksDB recovery are separate contracts. The
+standalone recovery protocol does not provide HStore multi-partition recovery or an
+atomic restore across all databases in a graph.
+
+## Guide map
+
+- [RPC and Scan ownership](#rpc-and-scan-ownership): final batches, half-close,
+ cancellation, iterator cleanup and callback dispatch.
+- [Shutdown coordination](#shutdown-coordination): admission, worker drain, TTL cleanup
+ and [Store node shutdown](#store-node-shutdown).
+- [Standalone snapshot recovery](#standalone-snapshot-recovery): pending records,
+ checkpoint identity, WAL paths and safe retry.
+- [Storage provider selection and ToplingDB](#storage-provider-selection-and-toplingdb):
+ JNI preparation, provider configuration and persistent paths.
+- [Runtime compatibility and upgrades](#runtime-compatibility-and-upgrades): backup,
+ JNI/JRaft compatibility, the compatibility runner and monitoring changes.
+
+## RPC and Scan ownership
+
+### Half-close, feedback and cancellation
+
+Request half-close is separate from transport cancellation.
+An aggregate query finishes work already supported by feedback credit after half-close;
+a later transport cancellation or deadline still cancels its workers. Graph partition
+scans deliver their permitted pages and final batch, or return an explicit error when
+half-close leaves insufficient feedback credit. Batch-scan RPCs accept one initial
+query.
+
+### Client response and iterator ownership
+
+Response parsing runs outside the client state lock. Accepted final batches remain
+visible across response completion; errors and iterator close discard unpublished data.
+Early query close cancels the transport without waiting for a blocked request send.
+Normal half-close stays serialized with sends. Closing a composite iterator closes all
+started children once. A closed multi-partition iterator is terminal: later probes do
+not reopen another partition, including after a child cleanup failure.
+
+Accepting a response refreshes the watchdog before parsing starts. Completion preserves
+an accepted batch while it is being parsed; cancellation and errors can discard it.
+The state lock protects these transitions rather than response extraction.
+
+### Server worker and cleanup ownership
+
+Each scan/query owns its workers and iterators until cleanup finishes. Iterator cleanup
+failures remain visible, including automatic native iterator close and empty-partition
+selection. Final aggregate success is sent only after plan and iterator cleanup
+succeeds.
+
+Cancellation signals the currently owned reader before waiting for its iterator lock.
+Publishing, interrupting and clearing that thread use the same ownership lock so a
+reused executor thread is not mistaken for the cancelled reader. Half-close does not
+interrupt the reader. Queued cancelled tasks still execute their cleanup/accounting
+paths, and iterator close waits for an active read to leave the iterator.
+
+Batch-scan request state is published under its manager lock, while reader notification,
+worker scheduling and iterator cleanup run outside that lock. A half-close or receipt
+waiting for a slow read therefore does not block cancellation from interrupting the reader.
+
+Unary query and count requests share the query admission, worker and cleanup owner
+registry. Count keeps partition work parallel on the service executor. Iterator cleanup
+failure prevents successful completion and remains visible to request drain.
+
+One-shot scans run on the gRPC caller thread. Transport cancellation and Store shutdown
+signal that caller directly, so interruptible waits can exit without waiting for a
+queued call-executor callback. Interruption is cooperative: a RocksDB native operation
+does not guarantee that it will return on Java thread interruption. The call remains
+registered until its read and iterator cleanup finish; cancellation does not permit
+another thread to close a native iterator that is still in use.
+
+### Server OLTP traversal batches
+
+Server OLTP traversal workers close each active batch iterator on completion,
+interruption or failure, then release their thread's graph transactions. A capacity
+stop also releases producers waiting on a full queue. Iterator cleanup errors remain
+visible when the capacity stop ends traversal normally; a business failure retains
+its cleanup errors as suppressed exceptions. Caller transactions remain with the
+request thread.
+
+### Callback executor configuration
+
+The gRPC application callback queue is always unbounded (`Integer.MAX_VALUE`), matching
+its existing default. `thread.pool.grpc.queue` remains accepted for configuration
+compatibility but no longer limits this callback queue. Bounded callback dispatch can
+reject cancellation or completion callbacks in gRPC 1.55.3, leaving request cleanup
+registered after the transport has closed. `thread.pool.grpc.core` and `.max` still
+configure the same executor; an unbounded queue normally keeps dispatch at the core
+thread count. Bound expensive scan/query work and admission at their worker queues;
+sustained overload can otherwise increase callback backlog and memory use.
+
+## Shutdown coordination
+
+RPC and Scan drain establishes completion of their request owners. It does not by itself
+establish completion of unrelated background maintenance jobs.
+
+The RPC boundary stops admission to every Store RPC before cancelling active scans and
+queries. It runs queued request cleanup and waits for terminal application callbacks
+before Spring destroys their databases. This reuses the same per-call owners; it does
+not create a second shutdown registry. Interrupted cleanup waits and native teardown
+preserve interruption without abandoning these owners.
+
+The Store context-close listener stops RPC, scan and query admission, requests transport
+shutdown, and drains scan/query workers before waiting for terminal gRPC callbacks and
+scan cleanup. Queued cancelled scans still run their cleanup paths.
+
+Shutdown terminates unfinished aggregate responses with `UNAVAILABLE`, rather than
+reporting normal completion for a truncated result. Terminal delivery is serialized
+with batch delivery and does not release the request's cleanup ownership: shutdown
+still waits for the workers and iterators even if the response callback fails.
+
+Transport termination alone is insufficient evidence that an application callback or
+native iterator owner has finished. Preserve the application cleanup barrier when
+changing the server framework, executor configuration or shutdown ordering.
+
+Drain waits retain ownership until cleanup finishes and restore the shutdown thread's
+interrupted status. They must not close databases under a worker solely because a wait
+was interrupted. A blocked owner can keep shutdown waiting; investigate that owner
+rather than bypassing the database lifetime guard.
+
+The engine closes partition databases after their partition engines shut down, then
+releases graph database and schema resources. Keep this ordering when modifying Raft or
+database teardown. Request drain and database release have different responsibilities;
+reuse their existing owners instead of adding a parallel registry.
+
+### Store node shutdown
+
+Spring coordinates Store shutdown: new RPCs are refused, active requests are cancelled,
+and their application callbacks and scan workers are drained before database teardown.
+The shutdown coordinator continues cancellation and independent cleanup even if one
+callback fails. It reuses the existing per-call owners.
+
+TTL cleanup closes the scan before returning its borrowed RocksDB session lease. Both
+releases are attempted when either fails. The first cleanup failure is retained, later
+failures are attached, and completion is signalled after these release attempts.
+Executor termination alone does not establish that native cleanup succeeded.
+
+Shutdown waits for TTL cleanup as well as ordinary request callbacks. Failed native
+releases remain diagnostic drain blockers. The engine stops heartbeat producers and
+joins partition Raft services before releasing native databases. Do not add a concurrent
+JVM shutdown hook that closes those databases independently of this drain.
+
+Cancellation can fail in-flight writes. Before planned maintenance, stop new writes and
+check outstanding write outcomes.
+
+For an unpacked distribution, invoke its normal stop script:
+
+```bash
+bash bin/stop-hugegraph-store.sh
+```
+
+The script waits up to 30 seconds for process exit. A timeout returns a nonzero status
+and retains `bin/pid` for diagnosis; it does not force-kill the process or remove data.
+A stuck callback or failed iterator close can keep shutdown pending. Inspect Store logs
+and a JVM thread dump to identify the remaining owner before retrying. Preserve the data,
+WAL and Raft directories, and do not bypass the resource drain to make the process exit.
+
+## Standalone snapshot recovery
+
+### Installation and pending metadata
+
+Standalone RocksDB snapshot restore retains its source checkpoint until the replacement
+is installed and reopened. Before changing data, it records the source checkpoint and
+WAL location in a sibling `.resume-pending` file, together with the restore
+operation and checkpoint generation. After an I/O failure or process interruption, a
+subsequent HugeGraph open retries the interrupted installation before native recovery.
+Missing sources, incomplete metadata or a changed WAL configuration stop opening instead
+of replaying uncertain data.
+
+### Filesystem durability boundaries
+
+On Linux, recovery synchronizes the marker parent directory before replacing live data
+and after removing the marker, before consuming the checkpoint. A directory-sync failure
+stops that step and preserves the checkpoint; after installation has succeeded, an
+unlink-sync failure leaves the restored live data intact. Other platforms retain their
+existing filesystem durability behavior. This marker ordering does not guarantee that
+every copied data or WAL file survives a power loss.
+
+### Publishing the pending record
+
+Recovery metadata publication requires same-directory hard-link support in the data
+directory's parent filesystem. The complete temporary record is written and forced
+before its final name is published without overwriting an existing marker. A
+pre-publication I/O or unsupported-link failure leaves live data and the checkpoint
+untouched; resolve the filesystem or permission fault, reopen normally and reissue
+restore. Existing damaged or old-format markers are still preserved and rejected.
+
+### Preparing application work
+
+Quiesce graph requests, background tasks and open query iterators before starting a
+restore. The recovery lease coordinates database opens and file installation; it does
+not drain active application work.
+
+### Retrying an interrupted restore
+
+Preserve the checkpoint, pending marker and configured paths after a failure. Restore
+access or free space, then retry normal startup with the same runtime and configuration.
+Do not delete the pending marker to bypass the guard. Successful native reopening clears
+it and then attempts checkpoint cleanup, preserving the existing consume-on-success
+behavior.
+
+The normal retry sequence is:
+
+1. Preserve the original checkpoint, pending record, WAL location and mount layout.
+2. Correct the access, space or filesystem fault without changing their identities.
+3. Release the remaining Store sessions with normal close calls when the failed restore
+ has already closed the native handle.
+4. Reopen with the same runtime and configuration so startup retries the recorded operation.
+5. Allow successful native reopening to clear the record and perform checkpoint cleanup.
+
+If native close itself failed, use the failure handling below instead of attempting a
+competing open.
+
+### Recovery ownership and native close failures
+
+A sibling `.resume-lock` serializes cooperating opens and restores. The OS
+lock remains held through native close and reopen, and is released when the database
+finally closes. The lock file remains on disk; its presence alone does not prove an
+active owner. Do not delete it to force another opener through. Within one JVM,
+physical-file ownership also covers path aliases. An unrelated database can still open
+while another database is closing. A failed native or lock-descriptor close retains
+recovery ownership and reports the failure; retrying close cannot bypass it. Preserve
+the recovery files and stop the process before retrying startup after such a failure.
+Older binaries and unrelated writers do not honor this protocol and must not access
+these directories concurrently.
+
+### Mount layout and physical path identity
+
+Mount the parent data root, such as `rocksdb-data`, rather than an individual store
+directory such as `data/g`. Java opening rejects a store that is itself a volume mount,
+including same-filesystem bind mounts on Linux. Such aliases can hide the sibling
+guards, and directory replacement cannot replace a mount point. This Java check is per
+database: it does not promise a pre-JVM scan of every graph or that no sibling database
+has initialized before another open fails. Keep mount layout unchanged throughout
+startup and recovery. Checkpoint/live-data overlap and a checkpoint containing the WAL
+directory are rejected by physical directory identity, including parent bind aliases;
+separate path strings do not make overlapping trees safe. Recovery refuses the operation
+if a required physical identity cannot be determined.
+
+### WAL replacement and recovery scope
+
+Independent WAL, WAL inside data, and data inside a WAL root use in-place log
+replacement. Preserve WAL symlink configuration across retries. Local fault tests cover
+interrupted operations and reopening; they do not establish power-cut durability. This
+mechanism does not provide an atomic whole-graph restore or an HStore multi-partition
+snapshot protocol.
+
+### Checkpoint generation and identity
+
+Each non-consuming restore creates a unique checkpoint directory. Pending recovery binds
+the original directory and immutable SST filesystem identities, plus checkpoint metadata
+and WAL contents, so replacing a checkpoint at the same path is rejected before data
+replacement. Keep the original checkpoint in place: copying it elsewhere and back may
+change its identity. Filesystems without file identities use content hashes instead.
+Unrelated writers must not modify checkpoint files; this protocol does not protect
+against malicious in-place changes to immutable SST files.
+
+### WAL staging and retained material
+
+WAL staging uses a sibling `.resume-staging-` directory recorded
+by the pending operation. Retired WAL remains there until successful native reopening;
+repeated attempts reuse that same directory. Cleanup removes only the current
+operation's directory, never all matching prefixes. Unknown staging directories and
+markers from older formats remain preserved for diagnosis rather than being guessed or
+silently upgraded.
+
+### Store-level retry
+
+After a Store-level restore failure closes its native owner, reopen the database to
+retry the recorded pending operation. Calling restore again on the already closed Store
+does not reopen it. Recovery retries the recorded unique checkpoint; it does not create
+a new non-consuming copy.
+
+### Non-consuming restore and temporary-copy cleanup
+
+Non-consuming Store restore first creates all temporary checkpoints, then restores each
+database. If either phase fails, it attempts to remove only the exact temporary copies
+created by that invocation, including an unfinished checkpoint created before its path
+could be returned. Cleanup requires the same database recovery ownership and a confirmed
+absent pending marker. Any existing or unreadable marker preserves the copy and emits a
+diagnostic; lock contention also preserves it. Original user checkpoints and unrelated
+temporary directories are never part of this cleanup. This does not collect historical
+orphan directories or copies left by a process killed before writing its pending marker.
+Restore remains non-atomic across databases.
+
+### Metadata integrity and runtime changes
+
+Pending records include a SHA-256 checksum over all metadata property names and values,
+excluding the checksum itself. Recovery verifies it before reconstructing any WAL alias,
+then uses the filesystem's actual canonical WAL path to validate the destination. This
+detects incomplete or changed metadata; it is not authentication against someone who can
+edit the record and recompute its checksum, and it does not freeze the surrounding
+filesystem. Records from older versions without this checksum are rejected and
+preserved, not silently upgraded. Finish an outstanding restore with its producing
+version before upgrading; otherwise preserve the checkpoint, marker and paths for
+diagnosis. Do not add a checksum manually to bypass this check.
+
+### Pending recovery blocks new checkpoint copies
+
+Before creating a non-consuming copy, restore holds the database recovery ownership and
+requires a confirmed absent pending marker. An existing or unreadable marker rejects the
+new copy before opening its checkpoint source; reopen the database to retry the recorded
+recovery instead. Repeated requests therefore do not allocate more UUID copies for an
+already pending database. If a later database is pending, copies already created for
+earlier databases are still cleaned by the same invocation's failure handling.
+
+### Database directory aliases
+
+The database directory itself must not be a symbolic link, including a dangling link;
+put a stable alias on its parent directory instead. This keeps the pending marker and
+recovery lock at the same physical database identity while its contents are replaced.
+Parent-directory symbolic links and bind mounts remain supported. Data and WAL aliases
+of the same physical directory are treated as one directory, so checkpoint WAL records
+are not deleted as a separate source.
+
+### WAL symbolic links
+
+Simple WAL symbolic links remain supported, including an external stable link followed
+by `..`. Recovery rejects a WAL path combining `..` with a link inside the data
+directory, or a symbolic-link target chain containing another link inside data: data
+replacement would delete an unrecorded part of that path. Reconfigure such layouts to
+use a stable external WAL directory before restoring. Rejection happens before writing
+new pending metadata or deleting data. If marker existence cannot be determined, opening
+fails with the recovery material preserved rather than bypassing a possible pending
+restore.
+
+## Runtime compatibility and upgrades
+
+The standard runtime uses RocksDB JNI 8.10.2. Validate existing data before upgrading
+the storage runtime. Keep a restorable backup made by the old runtime; do not
+assume that data opened or modified by 8.10.2 can be reopened by an older runtime.
+Rollback requires restoring the backup with the matching old application and
+dependencies, rather than replacing the JAR in an upgraded data directory.
+
+All components use JRaft 1.3.14. Older JRaft versions call compressed-cache methods removed from RocksDB 8.x, preventing
+Raft storage initialization, including Server when `raft.mode=true`. Treat the JNI and
+JRaft changes as one service upgrade and validate existing Raft logs with the complete
+new package.
+
+Use the complete matching application package for an upgrade or backup-based rollback.
+Validate service-created data and Raft logs separately from the synthetic JNI check. A
+successful JNI compatibility run does not establish that a cluster rolling upgrade or
+downgrade is safe.
+
+### RocksDB compatibility runner
+
+The permanent [compatibility
+test](../hugegraph-server/hugegraph-test/src/test/rocksdb-compatibility/run.sh) uses
+Maven effective POMs to compare the actual Server, PD and Store RocksDB versions. CI
+compares PR base/head or push before/after, fails if a version cannot be resolved, and
+runs only changed version pairs, deduplicated across components. POM or JRaft changes
+alone do not run the JNI test. No historical version matrix is maintained.
+
+Run from two complete source checkouts with Java 11+, Maven and Python 3:
+
+```bash
+bash hugegraph-server/hugegraph-test/src/test/rocksdb-compatibility/run.sh \
+ /path/to/before /path/to/after /tmp/new-rocksdb-compatibility-run
+```
+
+Each pair creates synthetic SST and synchronous WAL data with the old JNI,
+reads/modifies/deletes/adds data with the new JNI, then verifies it in a separate JVM.
+Original data, commands, JAR hashes, native versions and failure evidence are retained
+in the new output directory. This test never opens business data. It does not run at application startup or enter a
+production binary distribution. When changing this test or its CI,
+verify both changed/unchanged version selection and run the changed version pairs.
+Service-created data, Raft logs, cluster upgrades and Topling acceptance remain separate
+checks.
+
+The runner requires a new output directory and refuses environment overrides that inject
+Java options or native libraries. Before running, unset `JAVA_TOOL_OPTIONS`,
+`JDK_JAVA_OPTIONS`, `_JAVA_OPTIONS`, `LD_PRELOAD` and `DYLD_INSERT_LIBRARIES`. Keep each
+run's synthetic data and evidence separate; the script does not reuse a previous output
+directory.
+
+### Monitoring compatibility
+
+The JNI upgrade removes registration of these unavailable ticker constants:
+
+- `BLOCK_CACHE_INDEX_BYTES_EVICT`, `BLOCK_CACHE_FILTER_BYTES_EVICT`
+- `NO_FILE_CLOSES`, `RATE_LIMIT_DELAY_MILLIS`, `NO_ITERATORS`
+- `NUMBER_FILTERED_DELETES`
+- `BLOCK_CACHE_COMPRESSED_MISS`, `BLOCK_CACHE_COMPRESSED_HIT`,
+ `BLOCK_CACHE_COMPRESSED_ADD`, `BLOCK_CACHE_COMPRESSED_ADD_FAILURES`
+- `WRITE_TIMEDOUT`
+
+It also removes these histogram constants:
+
+- `STALL_L0_SLOWDOWN_COUNT`, `STALL_MEMTABLE_COMPACTION_COUNT`,
+ `STALL_L0_NUM_FILES_COUNT`
+- `HARD_RATE_LIMIT_DELAY_COUNT`, `SOFT_RATE_LIMIT_DELAY_COUNT`
+
+Store meter names use `rocks.stats.` plus the lowercase constant name. For histograms,
+the removed meters include `.max`, `.mean`, `.min`, `.summary` (with its quantile tags),
+`.summary.sum` and `.summary.count`. Exporters may normalize names further. Update
+dashboards and alerts that reference these series; their disappearance is not a zero
+reading. No replacement series or semantic equivalence is asserted here. See
+[RocksDBMetricsConst](../hugegraph-store/hg-store-node/src/main/java/org/apache/hugegraph/store/node/metrics/RocksDBMetricsConst.java)
+and [meter
+registration](../hugegraph-store/hg-store-node/src/main/java/org/apache/hugegraph/store/node/metrics/RocksDBMetrics.java).
+
+## Storage provider selection and ToplingDB
+
+HugeGraph defaults to standard RocksDB. Server, PD and Store can explicitly select a
+prepared ToplingDB runtime using their normal distributions. Prepare and configure each
+component separately; this guide covers the shipped launch scripts, not custom IDE or
+embedded launchers.
+
+| Component | Business provider setting | Start command |
+| --- | --- | --- |
+| Server with the RocksDB backend | `rocksdb.provider=topling` in each graph properties file | `bin/start-hugegraph.sh` |
+| PD | `rocksdb.provider: topling` in `conf/application.yml` | `bin/start-hugegraph-pd.sh` |
+| Store | `rocksdb.provider: topling` in `conf/application-pd.yml` | `bin/start-hugegraph-store.sh` |
+
+For a distributed graph, Server uses `backend=hstore`; PD and Store own its persistent
+storage. A standalone Server keeps `backend=rocksdb`. The launcher selection and the
+business provider must agree. A mismatch fails before opening that component's database.
+
+### Standalone Server setup
+
+
+
+Use a stopped Server distribution on Linux x86_64 with a Java 17 JDK. If your
+distribution lacks these scripts, [build it from this source](#build-and-prepare) first.
+Prepare the runtime once, select it, then initialize and start the Server. The graph
+APIs and normal start/stop commands stay the same; Topling's native options are
+configured in `conf/toplingdb.yaml`. Use new, empty, service-writable data and WAL
+directories outside the distribution for this example.
+
+**1. Prepare the runtime.** Supply your ToplingDB JNI JAR and its trusted SHA-256:
+
+```bash
+export TOPLING_JNI_JAR=/absolute/path/to/rocksdbjni-topling.jar
+export TOPLING_JNI_SHA256=''
+bash bin/prepare-topling.sh
+```
+
+**2. Select Topling.** Edit `conf/graphs/hugegraph.properties` and every other graph loaded from the configured graphs directory:
+
+```properties
+backend=rocksdb
+rocksdb.provider=topling
+rocksdb.data_path=/srv/hugegraph/topling/server/data
+rocksdb.wal_path=/srv/hugegraph/topling/server/wal
+```
+
+Create those external directories with permissions for the service account. The paths
+above are examples; choose persistent locations for your deployment.
+
+**3. Initialize and start.** From the same shell, select the matching JNI runtime and initialize the new store once:
+
+```bash
+export TOPLINGDB_ROCKSDB_PROVIDER=topling
+bash bin/init-store.sh
+bash bin/start-hugegraph.sh
+```
+
+Keep `TOPLINGDB_ROCKSDB_PROVIDER=topling` in the service environment for subsequent
+starts; initialization is only needed for a new store. For PD and Store, prepare each
+component separately, then use its YAML provider setting and start command from the
+table above. A Server connected to distributed storage keeps `backend=hstore`; PD and
+Store select their own storage runtime.
+
+### Build and prepare
+
+Build the normal distributions from the repository root using Java 17 and Maven 3.6.3 or
+later:
+
+```bash
+mvn clean package -Dmaven.test.skip=true -Dmaven.javadoc.skip=true
+```
+
+Obtain a trusted ToplingDB JNI JAR and its SHA-256 through your approved artifact
+channel. The runtime is not downloaded by the launcher. It must implement the
+`TOPLINGDB_EASY_MIGRATE_CONF` configuration entry point. Preparation requires a Java 17
+JDK, Linux x86_64, `sha256sum`, `unzip`, `od`, `ldd` and GNU `mv`. Native dependencies depend on the selected JNI JAR; builds requiring glibc 2.38 or
+newer and libaio need those libraries on the deployment host. Preparation rejects
+unresolved dependencies.
+
+In each stopped, unpacked component directory:
+
+```bash
+export TOPLING_JNI_JAR=/absolute/path/to/rocksdbjni-topling.jar
+export TOPLING_JNI_SHA256=''
+bash bin/prepare-topling.sh
+```
+
+The script verifies the copied JAR's hash, the Topling Java marker, one Linux x86_64 JNI
+entry, the ELF architecture and native dependencies. Before installation, it opens and
+closes a disposable database with the selected JNI and checks that a probe configuration
+changes the persisted write-buffer size. A marker or matching hash alone is
+insufficient: builds without the EasyMigrate configuration entry point are rejected. The
+probe validates the configuration entry point, not existing-data compatibility or every
+setting in your production profile. Its checksum receipt binds the probe to the
+installed JAR and native library. Launchers reject missing or stale receipts; prepare a
+fresh distribution when upgrading from an older preparation script. It installs
+`topling/rocksdbjni.jar`, its matching native library and optional web resources outside
+the standard `lib` directory. On Ubuntu with libaio's t64 name, the compatibility
+symlink stays inside that component's runtime directory. The published runtime
+directories use 0755 and regular files use 0644, so a different service account can read
+the prepared JNI. That account also needs traversal permission on the component's parent
+directories; preparation does not change those parents.
+
+Preparation refuses an existing `topling` directory. To replace a runtime, stop the
+service and prepare a fresh distribution; it never changes database files.
+
+### Select and start
+
+Set the provider in the component's business configuration. For PD and Store, edit the
+existing `rocksdb` mapping, retaining its other options:
+
+```yaml
+rocksdb:
+ provider: topling
+```
+
+For a standalone Server, edit every selected graph configuration:
+
+```properties
+backend=rocksdb
+rocksdb.provider=topling
+```
+
+Use separate, initially empty data directories for validation. This integration does not
+convert or certify an existing database for a different provider.
+
+For persistent use, create service-writable directories outside source checkouts, build
+outputs and unpacked distributions, and configure all applicable paths:
+
+| Component | Configuration file | Setting | Example external path |
+| --- | --- | --- | --- |
+| Server data | Graph properties | `rocksdb.data_path` | `/srv/hugegraph/topling/server/data` |
+| Server WAL | Graph properties | `rocksdb.wal_path` | `/srv/hugegraph/topling/server/wal` |
+| PD metadata | `conf/application.yml` | `pd.data-path` | `/srv/hugegraph/topling/pd` |
+| Store data | `conf/application.yml` | `app.data-path` | `/srv/hugegraph/topling/store/data` |
+| Store Raft | `conf/application.yml` | `app.raft-path` | `/srv/hugegraph/topling/store/raft` |
+
+Set both Server data and WAL paths. Retaining the default distribution-relative WAL path
+can lose that directory when rebuilding or replacing the distribution. Preserve the
+external data, WAL and Raft roots across runtime updates. Start each component with
+explicit runtime selection:
+
+```bash
+export TOPLINGDB_ROCKSDB_PROVIDER=topling
+# Run the start command for this component from the table above.
+```
+
+The component uses its own `conf/toplingdb.yaml`. An explicit
+`TOPLINGDB_EASY_MIGRATE_CONF=/absolute/path/config.yaml` overrides that native
+configuration. The shipped profile disables the native HTTP server and keeps
+`memtable_as_log_index=false`, which is required by the Java write path. Tune the native
+profile for your machine before production use; it does not replace the component's
+business configuration.
+
+Server's `init-store.sh` and `dump-store.sh` use the same explicit runtime selection. PD
+and Store put the prepared JNI JAR before their Spring Boot dependencies. Standard
+launch remains the default and does not require Topling files or preparation tools.
+Launch rejects inherited recognizable RocksDB JNI preloads in default/standard mode and
+competing JNI in Topling mode. Unrelated allocator or tracing preloads remain intact;
+switching from a previously selected Topling runtime removes only that runtime. Store
+adds verified jemalloc without replacing or duplicating caller preloads; the selected
+JNI stays first. Do not preload a second RocksDB JNI alongside the selected runtime.
+
+### Validate and stop
+
+Before adopting a JAR, validate each component's real writes, reads, normal stop and
+restart using a dedicated database directory. Confirm both the Java classes and the
+loaded JNI library come from the prepared runtime; a standard-JNI unit test or a
+successful startup alone is insufficient. For distributed storage, validate PD and Store
+together, then perform a Server `hstore` graph operation.
+
+At request/task completion, explicitly committed writes are preserved; unfinished writes
+are rolled back and cached backend leases are released. Shared schema and element caches
+retain their invalidation listeners until the graph closes.
+
+Stop incoming work and use the component's normal stop script. PD drains its scheduled
+metadata work, joins Raft and closes the metadata database and native options. Do not
+use a forced kill as evidence of normal resource cleanup.
+
+To return to standard RocksDB, stop the component, unset `TOPLINGDB_ROCKSDB_PROVIDER`
+and set its business provider back to `rocksdb`. Use the corresponding standard-provider
+data directory; do not test a provider switch by reusing the other provider's database.
+
+## Implementation navigation
+
+Use the current implementation and its tests when reviewing a lifetime change. These
+entry points separate client response ownership, server worker ownership, callback
+admission and standalone recovery installation.
+
+### RPC and shutdown entry points
+
+- [CommonKvStreamObserver](../hugegraph-store/hg-store-client/src/main/java/org/apache/hugegraph/store/client/query/CommonKvStreamObserver.java)
+ owns client stream state and response delivery.
+- [KvBatchScanner](../hugegraph-store/hg-store-client/src/main/java/org/apache/hugegraph/store/client/grpc/KvBatchScanner.java)
+ sends batch scan feedback and distinguishes early cancellation from half-close.
+- [ScanResponseObserver](../hugegraph-store/hg-store-node/src/main/java/org/apache/hugegraph/store/node/grpc/scan/ScanResponseObserver.java)
+ coordinates scan readers, cancellation and iterator cleanup.
+- [GrpcShutdownBarrier](../hugegraph-store/hg-store-node/src/main/java/org/apache/hugegraph/store/node/grpc/GrpcShutdownBarrier.java)
+ guards admission and waits for terminal application callbacks.
+- [ContextClosedListener](../hugegraph-store/hg-store-node/src/main/java/org/apache/hugegraph/store/node/listener/ContextClosedListener.java)
+ orders RPC shutdown, worker drain and callback completion.
+- [GRpcServerConfig](../hugegraph-store/hg-store-node/src/main/java/org/apache/hugegraph/store/node/grpc/GRpcServerConfig.java)
+ configures application callback dispatch.
+- [HgStoreEngine](../hugegraph-store/hg-store-core/src/main/java/org/apache/hugegraph/store/HgStoreEngine.java)
+ orders partition shutdown and database release.
+
+### Recovery and compatibility entry points
+
+- [RocksDBSnapshotRestore](../hugegraph-server/hugegraph-rocksdb/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBSnapshotRestore.java)
+ implements pending recovery and filesystem installation.
+- [RocksDBSessions](../hugegraph-server/hugegraph-rocksdb/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBSessions.java)
+ manages database sessions and recovery ownership.
+- [Snapshot restore tests](../hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBSnapshotRestoreTest.java)
+ exercise restore failures, path identities and retry behavior.
+- [Compatibility runner](../hugegraph-server/hugegraph-test/src/test/rocksdb-compatibility/run.sh)
+ selects changed JNI version pairs and runs isolated synthetic data checks.
+- [CI guidance](ci.md) describes how to select relevant checks for a change.
+
+Keep operational contracts here and deployment topology in the [Store operations
+guide](../hugegraph-store/docs/operations-guide.md) and [deployment
+guide](../hugegraph-store/docs/deployment-guide.md).
diff --git a/helm/hugegraph/.helmignore b/helm/hugegraph/.helmignore
new file mode 100644
index 0000000000..e37a0cdfb0
--- /dev/null
+++ b/helm/hugegraph/.helmignore
@@ -0,0 +1,38 @@
+# 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.
+
+# Patterns to ignore when building packages.
+.DS_Store
+.git/
+.gitignore
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+.idea/
+.vscode/
+
+# Contributor tooling, if present in a working tree. Never ship it.
+scripts/
+testdata/
+/tests/
+
+# Source documentation diagrams are not runtime chart files. Helm stores these
+# files in the release Secret; binary assets can exceed Kubernetes' 1 MiB limit.
+/images/
+
+# Never package a chart archive inside a chart.
+*.tgz
diff --git a/helm/hugegraph/Chart.yaml b/helm/hugegraph/Chart.yaml
new file mode 100644
index 0000000000..16397760b1
--- /dev/null
+++ b/helm/hugegraph/Chart.yaml
@@ -0,0 +1,38 @@
+#
+# 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.
+#
+
+apiVersion: v2
+name: hugegraph-helm
+description: Helm chart for Apache HugeGraph HStore cluster (PD + Store + Server)
+type: application
+version: 0.2.0
+# TODO: set appVersion to "1.8.0" when the release images are published.
+appVersion: "latest"
+kubeVersion: ">=1.23.0-0"
+keywords:
+ - hugegraph
+ - graph
+ - hstore
+ - raft
+home: https://hugegraph.apache.org/
+icon: https://hugegraph.apache.org/img/logo.svg
+sources:
+ - https://github.com/apache/hugegraph
+maintainers:
+ - name: HugeGraph Community
+ url: https://hugegraph.apache.org/
+ email: dev@hugegraph.apache.org
diff --git a/helm/hugegraph/README.md b/helm/hugegraph/README.md
new file mode 100644
index 0000000000..5f12ba6435
--- /dev/null
+++ b/helm/hugegraph/README.md
@@ -0,0 +1,269 @@
+# HugeGraph HStore Helm Chart
+
+Deploys **3 PD + 3 Store + 3 Server and Hubble** by default, with authentication
+and persistent storage enabled. Requires **Helm 3.10+ and Kubernetes 1.23+**.
+The OCI chart is named `hugegraph-helm`; the application and default Kubernetes resource names remain
+`hugegraph`. This chart targets HugeGraph 1.8.0 and later; earlier service releases
+are not supported.
+
+Provide a default StorageClass or prepare matching PVs. The default installation
+needs **7 volumes**: 3 × 10 GiB for PD, 3 × 50 GiB for Store, and 1 × 1 GiB
+for Hubble. The chart creates PVCs; it does not create a storage provisioner.
+
+## Install a published Chart
+
+For a quick trial, install the latest available Chart from GHCR:
+
+```bash
+kubectl config current-context
+kubectl get storageclass
+helm install hugegraph oci://ghcr.io/hugegraph/hugegraph-helm --devel \
+ -n hugegraph --create-namespace --wait --timeout 15m
+```
+
+> [!TIP]
+> `--devel` selects the highest Chart version, including development and stable
+> versions, without limiting the version series. For production or reproducible
+> installs, replace it with `--version ` and review the images.
+
+See [distribution and offline installation](docs/distribution.md) for Docker Hub,
+the single-node preset and internal OCI/HTTP repositories. Development Charts may
+use `latest` images; pin component `image.digest` values when needed.
+The source installation and configuration examples below assume a repository
+checkout and commands run from its root.
+
+## Install from source and open Hubble
+
+```bash
+helm install hugegraph ./helm/hugegraph -n hugegraph \
+ --create-namespace --wait --timeout 15m
+
+# Find assigned ports and a reachable node IP
+kubectl -n hugegraph get svc hugegraph-server hugegraph-hubble
+kubectl get nodes -o wide
+
+# Read the initial admin password
+kubectl -n hugegraph get secret hugegraph-admin \
+ -o jsonpath='{.data.password}' | base64 --decode
+```
+
+Open **`http://:`** and log in as `admin` with that
+password. Server API uses `http://:`.
+HTTP works directly; a hostname, certificate or extra acknowledgement is not
+required. PD remains internal.
+
+If node access is unavailable, use port-forward:
+
+```bash
+kubectl -n hugegraph port-forward svc/hugegraph-hubble 8088:8088
+# Open http://127.0.0.1:8088
+```
+
+Commands assume release/namespace `hugegraph`. For another release, use the names
+shown by `kubectl get svc,secret -n `. Default workloads use
+`-`; generated Secrets use `-admin`,
+`-auth-token`, and `-pd-auth`.
+
+## Common configurations
+
+### API only
+
+Hubble is optional. Current Hubble login requires Server authentication.
+
+```bash
+# Authenticated API, without the UI
+helm install hugegraph ./helm/hugegraph -n hugegraph --create-namespace \
+ --set hubble.enabled=false
+
+# Also disable API authentication, if wanted
+# Add: --set server.auth.enabled=false
+```
+
+Inactive credential values may remain in the values file when auth is disabled.
+
+### Internal image registry
+
+Mirror the four component images (`hugegraph/pd`, `hugegraph/store`,
+`hugegraph/server`, `hugegraph/hubble`) at the configured tags or digests:
+
+```bash
+helm install hugegraph ./helm/hugegraph -n hugegraph --create-namespace \
+ --set global.imageRegistry=registry.internal.example.com
+```
+
+A component repository with an explicit registry takes priority over the global
+prefix. Set `imagePullSecrets` if the registry needs credentials.
+Store's wait init container and Helm test reuse the Store and Server images,
+which already contain `curl`; no separate curl image is needed by default.
+See the [complete registry example](examples/values-internal-registry.yaml).
+
+### Storage
+
+Use a named dynamic StorageClass when the cluster has no suitable default:
+
+```bash
+helm install hugegraph ./helm/hugegraph -n hugegraph --create-namespace \
+ --set pd.storage.storageClassName=my-csi \
+ --set store.storage.storageClassName=my-csi \
+ --set hubble.persistence.storageClassName=my-csi
+```
+
+Empty `storageClassName` inherits the cluster default. `"-"` explicitly selects
+no StorageClass. Neither creates a provisioner. For local disks, prepare host
+directories and PVs first; the [seven-volume local example](examples/README.md#storage)
+includes node affinity, capacities and scheduling requirements.
+
+For a **new temporary installation**, add `--set persistence.enabled=false`
+to use `emptyDir`. Component storage flags override that global setting.
+PVC failures never cause an automatic fallback. Do not switch initialized
+PD/Store data between persistent and temporary storage.
+
+### Server service identity
+
+`server.serviceId` and `server.graphSpace` select the Server's PD service metadata.
+Both default to `DEFAULT`, which preserves the image configuration. Non-default
+values inject `HG_SERVER_SERVICE_ID` and `HG_SERVER_GRAPHSPACE` for the image
+entrypoint. Service IDs accept printable ASCII, including spaces and backslashes;
+the full name, including `/` and `-`, survives metadata reload and graph-space
+clearing. Dollar signs remain literal through Kubernetes environment expansion.
+Graph spaces must be `DEFAULT` or up to 48 lowercase letters, digits and
+underscores, starting with a letter. Create the target space before starting its
+assigned Server; these settings do not create it.
+
+```bash
+helm upgrade --install hugegraph ./helm/hugegraph -n hugegraph \
+ --set-string server.serviceId=analytics \
+ --set-string server.graphSpace=team_a \
+ --set-string server.testGraphAlias=team_a-demo
+```
+
+Use a chart and Server image build from the same source version containing these
+mappings. Older release images without them ignore these environment settings;
+select a matching image build explicitly. The chart does not detect old images.
+The Server's existing `SERVICE_ID` normalization (`GraphManager.serviceId()`) is
+unchanged: select `GRAPHSPACE` and the complete raw `SERVICE_NAME` in discovery
+queries. Identifier-only lookup has no uniqueness or isolation guarantee.
+
+`server.testGraphAlias` selects an existing Gremlin binding for every Server Pod,
+defaulting to `DEFAULT-hugegraph`. It does not create a graph or infer its binding
+from `server.graphSpace`. Match the binding's case: `Demo.properties` can register
+`DEFAULT-Demo`, while REST-created graph names are lowercase. A successful query
+of the default local binding does not verify a graph in `team_a`. Before replacing
+a Pod after `Could not rebind`, verify that the graph exists and the selected alias
+matches its binding; restarting cannot create a missing graph or correct an alias.
+
+These settings do not change the REST default path graph space or Hubble's
+selected service. Use explicit `/graphspaces/team_a/...` paths and the matching
+Hubble selection. Non-default discovery, authentication and graph-space isolation
+still need verification with a running Linux cluster. See the
+[container identity contract](../../docker/README.md#server-service-identity).
+
+## Modes and entry points
+
+`mode: easy` is the default. `mode: strict` changes Server/Hubble to ClusterIP
+and enables NetworkPolicy; authentication, Hubble and persistence stay enabled.
+Use `--set mode=strict` or `-f helm/hugegraph/values-strict.yaml`.
+
+**Explicit component value > global value > mode default.** An explicit `false`
+is preserved; null component values inherit. The global settings are
+`persistence.enabled` and `global.imageRegistry`. No mode requires HTTPS.
+
+| Entry | Access | When to use it |
+|---|---|---|
+| NodePort | Reachable node IP + assigned port | Direct access without another controller; easy default |
+| ClusterIP | Internal Service IP/DNS | In-cluster clients, an existing gateway or port-forward |
+| LoadBalancer | Infrastructure-assigned address | A load balancer controller is available |
+| Ingress | HTTP routes through a controller | Shared HTTP routing; hostname and TLS are optional |
+
+ClusterIP is an internal virtual service address, not a node IP. An ordinary
+laptop usually cannot reach it directly. With default `externalTrafficPolicy:
+Cluster`, NodePort can forward through a node that does not host the target Pod.
+
+To fix a NodePort, set `server.service.nodePort` or `hubble.service.nodePort`.
+Omission or `0` allocates automatically. Kubernetes validates the actual cluster
+range; the chart does not force `30000–32767`.
+
+A [hostless HTTP Ingress example](examples/values-http-ingress.yaml) is available.
+With NetworkPolicy enabled, add native rules for actual clients and controllers;
+empty external rule lists are valid but can block access. Explicit Service and
+policy values override either mode. Legacy exposure acknowledgement fields are
+accepted for compatibility and no longer gate rendering.
+
+## Common parameters
+
+Defaults below describe a fresh easy-mode install. Supply `--set` or a YAML file
+with `-f`; see [values.yaml](values.yaml) for all fields and their comments.
+
+| Parameter | Meaning | Default |
+|---|---|---|
+| `mode` | Entry and policy defaults | `easy` |
+| `pd.replicas` / `store.replicas` / `server.replicas` | Component replicas | `3` / `3` / `3` |
+| `hubble.enabled` | Install the UI | `true` |
+| `server.auth.enabled` | Server authentication | `true` |
+| `server.service.type` / `hubble.service.type` | Explicit entry type; null inherits mode | resolved `NodePort` |
+| `pd.service.type` | PD client entry type | `ClusterIP` |
+| `*.service.nodePort` | Fixed Server/Hubble port; omitted/0 allocates automatically | unset |
+| `global.imageRegistry` | Prefix image repositories without an explicit registry | `""` |
+| `*.image.repository` / `tag` / `digest` | Component image; digest takes priority | see values |
+| `persistence.enabled` | Global persistent storage | `true` |
+| `pd.storage.enabled` / `store.storage.enabled` / `hubble.persistence.enabled` | Explicit component storage override | `null` (inherits) |
+| `*.storageClassName` | Cluster default when empty; `"-"` means no class | `""` |
+| `pd.storage.size` / `store.storage.size` / `hubble.persistence.size` | Volume capacities | `10Gi` / `50Gi` / `1Gi` |
+| `server.serviceId` / `server.graphSpace` | PD service name and existing graph space | `DEFAULT` / `DEFAULT` |
+| `server.testGraphAlias` | Existing Gremlin binding checked by `helm test` | `DEFAULT-hugegraph` |
+| `server.storageWaitTimeoutSeconds` | Storage-ready deadline | `300` |
+| `networkPolicy.enabled` | Explicit policy override; null inherits mode | resolved `false` |
+
+
+Advanced settings and presets
+
+- [Operations](docs/operations.md): credentials, probes, upgrades, membership,
+ scheduling, policies and troubleshooting.
+- [Deployment examples](examples/README.md): private registry, local volumes,
+ fixed GitOps credentials, external Secrets, HTTP Ingress and API-only usage.
+- `values-single.yaml`: 1 PD + 1 Store + 1 Server, with Hubble and authentication.
+- `values-cluster.yaml`: required PD/Store anti-affinity, resources and controlled
+ Store updates. NetworkPolicy inherits mode; the preset is a capacity starting point.
+- All client Services support native `externalTrafficPolicy`, `loadBalancerClass`,
+ `loadBalancerIP`, `loadBalancerSourceRanges`, `externalIPs` and
+ `allocateLoadBalancerNodePorts`, subject to cluster/controller support.
+- Resources, JVM options, scheduling, probes, HPA/PDB, metadata and external
+ Hubble discovery are configured under their components in `values.yaml`.
+ Default images preserve automatic JVM sizing; set resources for your capacity.
+
+
+
+## Upgrade and verify
+
+```bash
+helm get values hugegraph -n hugegraph --all
+helm upgrade hugegraph ./helm/hugegraph -n hugegraph --reuse-values \
+ --wait --timeout 15m
+helm test hugegraph -n hugegraph --logs --timeout 5m
+```
+
+`--reuse-values` preserves existing UI, entry, storage and credential settings.
+An easy-mode upgrade that inherits Server/Hubble entry types prints a short
+NodePort-default notice in NOTES; explicitly configured entry types are preserved.
+To adopt new defaults, use `--reset-values` with a reviewed values file retaining
+all settings your release needs. Older explicit helper images also remain under
+value reuse; clear `store.waitImage` and `server.waitImage` to reuse component images.
+
+The admin Secret is an **initial credential**. Changing it alone does not rotate
+an existing account; follow [credential rotation](docs/operations.md#credentials).
+The chart checks persisted Raft identities and storage/membership changes during
+upgrades; those changes require a coordinated procedure.
+
+`helm --wait` checks readiness. `helm test` checks graph binding and visible
+associated indexes for `CREATED` status on each Server Pod. Run it after index
+creation/rebuild jobs finish, with no schema changes during the test. On HStore,
+reading an unfinished index can cache its temporary status on other Pods;
+retries do not refresh that state, and indexed queries may keep failing until
+those Pods restart after the jobs finish. It does not prove index query results; add
+[business assertions](docs/operations.md#business-query-assertions)
+with `--set-file server.testGremlin=assertions.groovy`. Also verify a fresh Hubble
+login and graph query. Templates and linting alone do not prove live volume,
+network or application behavior.
+
+Website guides: [Helm deployment](https://hugegraph.apache.org/docs/quickstart/hugegraph/hugegraph-helm/)
+and [Kubernetes operations](https://hugegraph.apache.org/docs/quickstart/hugegraph/hugegraph-helm-operations/).
diff --git a/helm/hugegraph/docs/distribution.md b/helm/hugegraph/docs/distribution.md
new file mode 100644
index 0000000000..8728df0b28
--- /dev/null
+++ b/helm/hugegraph/docs/distribution.md
@@ -0,0 +1,140 @@
+# Chart distribution and offline installation
+
+The OCI chart is named `hugegraph-helm`, independently of the HugeGraph service
+version. Its source remains in `helm/hugegraph`. The application name, default
+resource names and `nameOverride` / `fullnameOverride` behavior remain unchanged.
+Use Helm 3.10+ to render and install this Chart, including OCI support.
+
+This distribution targets HugeGraph **1.8.0 and later**. Development Charts may
+use `latest` component images; production deployments need a stable Chart and
+published fixed component images.
+
+## Choose a version and registry
+
+The same packaged chart is published to both registries:
+
+| Registry | Chart reference | Typical use |
+| --- | --- | --- |
+| GHCR | `oci://ghcr.io/hugegraph/hugegraph-helm` | Public CI |
+| Docker Hub | `oci://registry-1.docker.io/hugegraph/hugegraph-helm` | Alternative public entry point |
+
+GHCR is recommended for public CI. To use Docker Hub, replace `CHART` with its
+reference from the table:
+
+```bash
+CHART=oci://ghcr.io/hugegraph/hugegraph-helm
+helm install hugegraph "$CHART" --devel \
+ -n hugegraph --create-namespace --wait --timeout 15m
+```
+
+> [!TIP]
+> `--devel` selects the highest Chart version, including development and stable
+> versions, without limiting the version series. For production or reproducible
+> installs, replace it with `--version ` to pin your choice.
+
+[Artifact Hub](https://artifacthub.io/packages/helm/hugegraph-helm/hugegraph-helm)
+indexes versions and installation instructions. The Chart archive itself comes
+from GHCR or Docker Hub.
+
+For a single-node trial, use this instead of the default installation. Install
+the downloaded Chart so its bundled preset and templates use the same version:
+
+```bash
+CHART=oci://ghcr.io/hugegraph/hugegraph-helm
+helm pull "$CHART" --devel --untar
+helm install hugegraph ./hugegraph-helm -n hugegraph \
+ -f ./hugegraph-helm/values-single.yaml \
+ --create-namespace --wait --timeout 10m
+```
+
+Development packages append a workflow run number to the source chart version,
+for example `0.2.0-dev.123`. They may use `latest` service images. Helm OCI tags
+come from the chart's SemVer version; `helm-dev` and `latest` are not Chart versions.
+
+Formal packages use the exact `Chart.version` maintained in source, for example
+`0.2.0`. They are published only after the corresponding HugeGraph service release
+exists and the chart selects published fixed component images. A source tag
+such as `helm-1.8.0` therefore denotes an already released service, not an
+advance preview. Chart fixes may be released independently from a reviewed master
+commit after the service release; chart and service version numbers need not match.
+
+The manual workflow in [hugegraph/actions](https://github.com/hugegraph/actions)
+resolves its selected source ref to a fixed commit, validates and packages the
+chart, and installs that exact `.tgz` for its Kubernetes checks before publishing.
+Turning off `publish` runs validation without uploading to a registry.
+
+## Install and mirror a chart offline
+
+On an internet-connected machine, select the latest available version and
+record it before downloading. Transfer the `.tgz` and container images into
+your network. The archive contains no container images.
+
+```bash
+CHART=oci://ghcr.io/hugegraph/hugegraph-helm
+CHART_VERSION=$(helm show chart "$CHART" --devel | sed -n 's/^version: //p')
+helm pull "$CHART" --version "$CHART_VERSION"
+```
+
+Set your internal container registry in `private-images.yaml`. Preserve the
+component paths, such as `hugegraph/pd`, when mirroring the images:
+
+```yaml
+# private-images.yaml
+global:
+ imageRegistry: registry.internal.example.com
+```
+
+Choose one installation method below and replace the example addresses with
+your own. Each method installs the same Chart version and uses the private images.
+
+
+Install the archive directly
+
+```bash
+helm install hugegraph "./hugegraph-helm-${CHART_VERSION}.tgz" \
+ -n hugegraph --create-namespace -f private-images.yaml --wait --timeout 15m
+```
+
+
+
+
+Internal OCI registry
+
+```bash
+helm registry login registry.internal.example.com
+helm push "./hugegraph-helm-${CHART_VERSION}.tgz" \
+ oci://registry.internal.example.com/charts
+helm install hugegraph oci://registry.internal.example.com/charts/hugegraph-helm \
+ --version "$CHART_VERSION" -n hugegraph --create-namespace \
+ -f private-images.yaml --wait --timeout 15m
+```
+
+`helm push` takes the parent registry path; Helm adds the Chart name and version.
+
+
+
+
+Internal HTTP repository
+
+Upload the same `.tgz` using the repository's upload mechanism and update
+`index.yaml`. For a static HTTP repository, `helm repo index` creates the index.
+Native `helm push` only supports OCI.
+
+```bash
+helm repo add hugegraph-internal https://charts.internal.example.com
+helm repo update hugegraph-internal
+helm install hugegraph hugegraph-internal/hugegraph-helm \
+ --version "$CHART_VERSION" -n hugegraph --create-namespace \
+ -f private-images.yaml --wait --timeout 15m
+```
+
+
+
+Mirror the configured `hugegraph/pd`, `hugegraph/store`, `hugegraph/server` and,
+when enabled, `hugegraph/hubble` images at their selected tags or digests. The
+default Store wait container and Helm test hook reuse component images. Explicit
+`store.waitImage` / `server.waitImage` overrides must also be mirrored. Use
+`global.imageRegistry`, per-component repositories and `imagePullSecrets` as
+shown in the [private registry example](../examples/values-internal-registry.yaml).
+The mirror must preserve the selected image references and support the cluster's
+CPU architecture.
diff --git a/helm/hugegraph/docs/operations.md b/helm/hugegraph/docs/operations.md
new file mode 100644
index 0000000000..4c90e1e6ba
--- /dev/null
+++ b/helm/hugegraph/docs/operations.md
@@ -0,0 +1,359 @@
+# Helm operations
+
+Start with [installation and common settings](../README.md).
+[values.yaml](../values.yaml) documents every available parameter; this page
+covers settings that need coordination or diagnosis.
+
+Commands below use the local chart from the repository root. For a published
+Chart, replace `./helm/hugegraph` with
+`oci://ghcr.io/hugegraph/hugegraph-helm --version `.
+Keep the installed version for configuration-only changes; choose and review a
+new version for upgrades. See [version selection](distribution.md#choose-a-version-and-registry).
+
+## Credentials
+
+Normal Helm installations generate/reuse three kept Secrets:
+
+| Default Secret | Data key | Readers |
+|---|---|---|
+| `-admin` | `password` | Server, Helm test |
+| `-auth-token` | `token_secret` | All Server replicas |
+| `-pd-auth` | `secret-key` | PD, Server storage wait, Hubble |
+
+`existingSecret` takes priority over an inline value, which takes priority over
+auto-generation. Existing Secrets are not managed by the chart. Override their
+key names through the corresponding `key` fields.
+
+The admin password must be printable ASCII without spaces, colons or
+backslashes. The PD REST secret must be printable ASCII without surrounding
+whitespace or backslashes. JWT signing keys must be at least 32 bytes.
+These constraints match the current image's authentication/property encoding.
+
+The admin Secret supplies the **initial** password when the account is created.
+To rotate an existing account, first call the Server API with the old credential:
+
+```bash
+curl --user 'admin:' \
+ -X PUT -H 'Content-Type: application/json' \
+ -d '{"user_password":""}' \
+ "http:///graphspaces/DEFAULT/auth/users/admin"
+```
+
+Do not replace password placeholders with real credentials in recorded shell
+commands: they can enter history or briefly appear in process arguments.
+For interactive calls, `curl --user admin` (or `--user hg` for PD) prompts for
+the password. Variable-based examples avoid literal passwords in shell history.
+
+
+Optional credential files for shared terminals or repeated requests
+
+Create a curl config and password-update JSON with an editor or secret manager,
+using owner-only permissions (`600`). The config contains
+`user = "admin:"`; the JSON contains the `user_password` body above.
+Then use `--config /path/to/server.curl` and
+`--data-binary @/path/to/password-update.json` instead of inline credentials.
+For PD commands, use a separate config containing `user = "hg:"`
+and replace `--user "hg:${PD_SECRET}"` with `--config /path/to/pd.curl`.
+Do not log or commit these files; remove them when no longer needed.
+
+
+
+Update the Secret and its source values to the same new password, then restart
+all Server replicas to clear cached credentials:
+
+```bash
+kubectl -n hugegraph rollout restart deployment/hugegraph-server
+```
+
+Use your secret manager rather than storing real passwords in public files.
+GitOps needs fixed encrypted values or external Secrets, because offline
+renderers cannot reuse a live generated Secret through `lookup`.
+[Both complete examples](../examples/README.md#gitops-credentials) include all
+three credential sources. Changing PD credentials rolls PD, Server and Hubble;
+verify Gremlin and UI login after that coordinated rollout.
+
+### PD REST authentication
+
+PD REST uses HTTP Basic auth with a service username: `hg`, `store`, `hubble`
+or `vermeer`. Server's `admin` account is not a PD REST account and receives 401.
+All four service names use the shared PD REST password, not the admin password.
+For the default release/Secret, query a reachable PD REST address (port 8620):
+
+```bash
+PD_SECRET=$(kubectl -n hugegraph get secret hugegraph-pd-auth \
+ -o jsonpath='{.data.secret-key}' | base64 --decode)
+curl --user "hg:${PD_SECRET}" "http://:8620/v1/stores"
+```
+
+With `pd.auth.existingSecret` or a custom `pd.auth.key`, read the actual Secret
+and key instead. See the [PD REST API reference](../../../hugegraph-pd/docs/api-reference.md#rest-api)
+for probe exceptions and API details. Management tasks below must target the PD leader.
+
+## Storage and upgrades
+
+Inspect saved values before upgrading. `--reuse-values` preserves old effective
+settings, including disabled Hubble, ClusterIP and explicit helper images.
+Adopt new defaults only with reviewed values and `--reset-values`.
+
+PD/Store PVC size and StorageClass live in StatefulSet claim templates and are
+not routine mutable settings. For capacity growth, the StorageClass must support
+expansion: resize each PVC, wait for completion, then coordinate an orphaned
+StatefulSet recreation with matching claim-template values. Preserve Pod/claim
+names and persisted data; do not recreate claims as a resize workaround.
+
+PD/Store storage-mode changes are rejected after initialization. Hubble uses
+an embedded H2 database with connection metadata and saved graph credentials;
+a PVC and `Recreate` deployment strategy preserve it across Pod replacement.
+Disabling Hubble or uninstalling does not delete kept claims/Secrets automatically.
+Changing Hubble storage requires a separate reviewed metadata migration.
+
+An older Hubble Deployment may have no `data` volume: its H2 records live in
+its container filesystem. The new persistent default is a storage change too,
+so a live Helm upgrade rejects it rather than silently creating an empty database.
+Keep `hubble.persistence.enabled=false` with `--reuse-values` or an explicit
+GitOps value to preserve the previous storage mode. Move any records you need
+to retain through a reviewed metadata migration before enabling persistence;
+retaining the temporary mode does not make those records survive Pod replacement.
+Export or back up required metadata before any rollout of that old Pod, including
+an upgrade that retains `hubble.persistence.enabled=false`.
+Offline `helm template` and GitOps rendering cannot inspect the existing Deployment
+through `lookup`, so this live guard cannot enforce the migration requirement there.
+Export and explicitly pin the existing storage flags before a GitOps sync; a
+successful offline render does not establish a safe migration.
+
+Local PVs bind to nodes and cannot move their files automatically on node loss.
+See [all seven local PVs](../examples/README.md#storage) for directory, capacity,
+claim binding and scheduling requirements. `Retain` preserves files on claim
+deletion; reclamation/recovery needs explicit inspection.
+
+## Startup and probes
+
+Store's init container waits for a majority of PD peers on `store.waitPath`
+(default `/v1/ready`) for up to `store.waitTimeoutSeconds` (default 900).
+PD readiness requires a Raft leader. Images with the fatal-health fix return
+`503` from `/v1/health` for terminal Raft errors or shutdown, while elections
+and temporary quorum loss remain healthy. Older images only check the listener.
+Store's own readiness does not prove every shard has recovered.
+
+Server waits for storage for `server.storageWaitTimeoutSeconds` (default 300),
+passed as `WAIT_STORAGE_TIMEOUT_S`. An unspecified startup threshold is derived
+from that wait plus a 150-second startup allowance. Explicit thresholds are
+preserved and must leave at least 120 seconds beyond the storage wait.
+The probe budget is `initialDelaySeconds + (failureThreshold - 1) * periodSeconds`;
+without an initial delay, the first probe can run immediately. Contradictory budgets fail rendering with
+the relevant parameter names; `HG_SERVER_STARTUP_TIMEOUT_S` is chart-managed.
+Legacy reused values retain their earlier probe-budget compatibility behavior.
+
+`store.waitImage` and `server.waitImage` default empty and reuse the Store/Server
+images, respectively. Those images already include `/bin/sh` and `curl`.
+Explicit helper overrides remain supported for custom images. To clear an older
+override without resetting other release settings:
+
+```bash
+helm upgrade hugegraph ./helm/hugegraph -n hugegraph --reuse-values \
+ --set-string store.waitImage= --set-string server.waitImage=
+```
+
+## Topology and scheduling
+
+The startup wrapper sets `usePD=true` and the chart PD peers so all Servers
+share graph metadata. Distributed Servers keep `initStoreEnabled=false` and do
+not concurrently run `init-store`. Each Server registers its Pod address with
+PD unless `server.advertiseUrl` specifies one shared discovery address.
+
+At first bootstrap, the chart seeds three shard replicas with at least three
+Stores, or one otherwise. PD persists those settings. Chart changes after
+initialization do not rewrite the partition metadata; use the PD configuration
+API and coordinate partition patrol for existing groups.
+
+PD Raft addresses, Store Raft addresses and StatefulSet names are persisted
+identities. Do not change those hostnames/ports as an ordinary upgrade.
+PD membership changes and Store shrink require explicit member/shard operations;
+the chart rejects unsupported changes against initialized live StatefulSets.
+Fresh installs do not perform the upgrade-only StatefulSet query.
+
+For Store shrink, first confirm the remaining Stores can hold the configured
+shard replica count and data. Retire the leaving Store IDs through PD and run
+partition patrol on the PD leader. Wait until no shard group references a retired ID and
+all remaining groups have their full membership and one leader. Only then
+scale the live StatefulSet and upgrade with the matching `store.replicas`;
+PVC removal is a separate step after retirement. Keep the installed PD count
+unless its persisted voting configuration is reconfigured outside Helm; a
+fresh release with data migration is the alternative. The
+[PD management API](../../../hugegraph-pd/docs/api-reference.md) documents the
+Store state and task endpoints.
+
+Default preferred anti-affinity permits fewer nodes than replicas. The cluster
+preset requires separate nodes for PD/Store; explicit affinity overrides presets.
+Local PV node affinity must agree with scheduling rules. PDBs protect voluntary
+eviction, not controller-driven updates. PD needs a majority available; Store
+needs enough shard copies to remain available.
+
+Server replicas can be changed routinely; CPU-utilization HPA requires positive
+CPU requests. Store growth still needs volume and shard planning. Use chart
+updates for Store scale changes so Hubble's endpoint list is updated too.
+
+## Rolling Store updates and recovery
+
+Use `store.updateStrategy.type=OnDelete` for a controlled rollout. Replace one
+Store Pod at a time. Before replacing another, confirm the previous Pod is
+Ready, its Store heartbeat is fresh, and every shard group reports full
+membership and exactly one leader from the PD leader's view.
+PD `Up` status alone does not prove shard recovery; stop on missing members or
+leaders. The [website operations guide](https://hugegraph.apache.org/docs/quickstart/hugegraph/hugegraph-helm-operations/)
+explains the rollout and recovery constraints.
+A single Store replacement can still cause request errors or timeouts while Raft
+leadership and client routes converge. Observe application errors as well as
+shard recovery before continuing; do not blindly retry writes.
+
+### PD patrol and leader balance
+
+Partition patrol repairs shard membership; it does not balance shard leaders.
+After a Store replacement, leader counts can remain uneven because leader
+balancing is not scheduled automatically. Use the existing manual task when
+all Stores have fresh heartbeats and every group has full, healthy membership.
+
+Find the PD leader through authenticated `/v1/members` (`data.pdLeader.restUrl`).
+Forward that Pod in a separate terminal, not a randomly selected Service peer:
+
+```bash
+kubectl -n hugegraph port-forward pod/ 8620:8620
+```
+
+In the terminal holding `PD_SECRET` from the credentials section, check that
+`/v1/ready` reports `isLeader: true`, then inspect the current group and Store state:
+
+```bash
+PD_REST=http://127.0.0.1:8620
+curl "${PD_REST}/v1/ready"
+curl --user "hg:${PD_SECRET}" "${PD_REST}/v1/partitions"
+curl --user "hg:${PD_SECRET}" "${PD_REST}/v1/stores"
+```
+
+After an intentional member retirement or shard-count change, patrol on that
+leader if needed; it is not a leader-balancing command:
+
+```bash
+curl --user "hg:${PD_SECRET}" "${PD_REST}/v1/task/patrolPartitions"
+```
+
+Wait for membership recovery and split/move tasks to finish before requesting
+leader balance separately:
+
+```bash
+curl --user "hg:${PD_SECRET}" "${PD_REST}/v1/task/balanceLeaders"
+```
+
+A busy task returns body `status: 1001`; inspect the result and wait for current
+maintenance to finish rather than looping patrol/balance calls. A follower can
+return an empty success, and leader transfers are asynchronous: HTTP 200 or a
+returned transfer plan does not prove completion. Recheck `/v1/partitions` and
+`/v1/stores` for actual leaders, full membership and fresh heartbeats before
+continuing maintenance.
+
+Preserve volumes and recorded member identities during recovery. Deleting a
+RocksDB lock file or assigning an empty volume does not recover shard data.
+The chart does not provide backup or automatic disk-loss recovery; keep backups
+outside the data PVs and validate recovery separately.
+
+## Networking and Hubble
+
+Easy mode leaves NetworkPolicy off; strict enables it. Explicit values win.
+Policies require a network plugin that enforces them. Generated rules allow
+component communication and DNS; admit external clients/controllers with native
+`extraIngress`, and external addresses with appropriate `extraEgress`.
+Selecting strict mode and then overriding a Service to NodePort/LoadBalancer,
+setting `externalIPs`, or enabling Ingress does not automatically admit outside traffic. NOTES names
+exposed components with no external ingress rule and points to the matching
+`networkPolicy..extraIngress` setting; template rendering remains valid.
+For Hubble PD discovery via an external `server.advertiseUrl`, also configure
+Hubble's `extraEgress` for that destination.
+Empty external rule lists are valid and can block clients; `{}` is a valid
+allow-all rule. Consider NodePort source NAT when choosing source ranges.
+
+Client Services accept native Kubernetes traffic and load-balancer fields under
+`pd.service`, `server.service` and `hubble.service`. LoadBalancer-specific fields
+need that Service type and a compatible controller. Hostless HTTP Ingress is
+supported; install an Ingress controller separately. TLS is optional.
+
+In-chart Hubble is wired to internal PD/Server endpoints. `hubble.mode=direct`
+uses only the Server Service and has no PD operations view. External Hubble
+needs reachable Server addresses; PD discovery additionally needs reachable PD
+gRPC and a suitable `server.advertiseUrl`. That override affects every discovery
+client, including in-cluster Hubble. Current Hubble login requires Server auth;
+use `hubble.enabled=false` for an unauthenticated API-only deployment.
+
+API response compression follows `Accept-Encoding`. Clients that support gzip
+can request it explicitly or use `curl --compressed`; requests with `identity`
+or without a compression header receive plain responses. Quality factors `q=0.`
+and `q=1.` are equivalent to `q=0` and `q=1`. Explicit gzip exclusion overrides
+`*`; a higher `identity` quality selects a plain response.
+Older images can still force gzip; use a Server image built from current source.
+Updating chart values alone does not change an existing image's behavior.
+
+## Business query assertions
+
+The default `helm test` checks the `server.testGraphAlias` binding (`DEFAULT-hugegraph` by default)
+and requires `CREATED` indexes
+associated with the vertex/edge labels visible on each Pod. It reads those
+indexes by ID, using the same local cache path as queries; it does not refresh
+the full index cache. This does not detect missing/stale label associations,
+orphaned indexes or incorrect index data, and does not prove range-query results.
+Run it after index creation/rebuild jobs finish, with no schema changes during
+the test. Like indexed queries, the check rejects every other status, including
+`CREATING`, `REBUILDING` and `INVALID`. On HStore, reading an unfinished index can
+cache its temporary status on Pods other than the job runner. Later attempts
+reuse that cache; the retry window does not repair it. Indexed queries on those
+Pods may keep failing after the job succeeds; replace affected Server Pods only
+after the jobs finish. This check does not fix cross-Server schema cache consistency.
+
+For a graph with existing test data, provide a read-only Gremlin script that
+asserts the expected result. For example, with 40 existing `person` vertices
+(ages 0–39), save this as `assertions.groovy`, adapting names/results to your data:
+
+```groovy
+def n = graph.traversal().V().hasLabel('person')
+ .has('age', org.apache.tinkerpop.gremlin.process.traversal.P.gte(20))
+ .count().next()
+assert n == 20 : "Expected 20 adults, got ${n}"
+```
+
+```bash
+helm upgrade hugegraph ./helm/hugegraph -n hugegraph --reuse-values \
+ --set-file server.testGremlin=assertions.groovy
+helm test hugegraph -n hugegraph --logs --timeout 5m
+```
+
+In each attempt, the hook checks binding/index state on every Ready Server Pod,
+then runs optional business assertions on Pods where that check succeeds. A failing
+request or assertion reports its Pod IP, check stage and response, and fails
+the hook if no complete attempt succeeds before its deadline. Use
+`kubectl -n hugegraph get pods -o wide` to map IPs to Pod names. Returning a value
+or `false` alone is not a failure: use `assert` or throw an exception. Without a
+script, the hook explicitly reports business assertions as skipped. The hook
+creates no schema/data by default. Custom scripts use the hook's Server credential
+(`admin` when authentication is enabled); read-only execution is not enforced,
+so supply trusted, read-only assertions. The hook does not guarantee the first request
+after startup succeeds; its retries allow transient elections to settle.
+
+## Troubleshooting
+
+| Symptom | First checks |
+|---|---|
+| PVC Pending | StorageClass/provisioner, matching PVs, PVC and Pod events; initial `WaitForFirstConsumer` Pending can be normal |
+| Mount failure | Host directory, node affinity and image UID/GID permissions |
+| ImagePullBackOff | Pod events, registry reachability, image tags/digests and `imagePullSecrets` |
+| `HG_PD_AUTH_SECRET_KEY` missing | PD Pod Secret reference and key; chart injects the shared PD REST password automatically |
+| Server storage wait timeout | Ready PD majority, Store registration, network policies and `server.storageWaitTimeoutSeconds` |
+| NodePort/Ingress unreachable | Assigned port, reachable node/controller address, firewall and NetworkPolicy rules |
+| REST works but Gremlin reports `Could not rebind` | Run `helm test`. Server images built from source that includes apache/hugegraph#3351 (the fix for #3228) rebind Gremlin when REST loads the graph; if it persists, check the Server log for `could not be bound in Gremlin Server` or `can't be rebound in Gremlin Server`, then replace the failing Server Pod after PD stabilizes and retry |
+
+```bash
+kubectl -n hugegraph get pods,pvc,svc
+kubectl -n hugegraph describe pod
+kubectl -n hugegraph logs
+helm test hugegraph -n hugegraph --logs --timeout 5m
+```
+
+Never print credential values in diagnostic logs. Ready and `/versions` do not
+prove graph binding or fresh UI login; verify graph queries and a new login too.
diff --git a/helm/hugegraph/examples/README.md b/helm/hugegraph/examples/README.md
new file mode 100644
index 0000000000..1f748d70d7
--- /dev/null
+++ b/helm/hugegraph/examples/README.md
@@ -0,0 +1,140 @@
+# HugeGraph deployment examples
+
+These files are examples, not automatically installed resources. Replace registry,
+node names, paths, release/namespace and credentials for your cluster before use.
+Commands below are deployment instructions; authoring them does not deploy anything.
+
+## Storage
+
+The default topology needs **seven volumes**: 3 × 10 GiB for PD, 3 × 50 GiB for
+Store, and 1 × 1 GiB for Hubble. With a default dynamic StorageClass, ordinary
+`helm install` requests them automatically. For a named dynamic StorageClass:
+
+```bash
+helm install hugegraph ./helm/hugegraph -n hugegraph --create-namespace \
+ --set pd.storage.storageClassName=my-csi \
+ --set store.storage.storageClassName=my-csi \
+ --set hubble.persistence.storageClassName=my-csi
+```
+
+An empty chart `storageClassName` inherits the cluster default; `"-"` renders
+an explicit empty Kubernetes StorageClass and disables default-class assignment.
+Neither creates a provisioner. PVCs remain Pending without matching PVs.
+
+### Static local volumes
+
+[local-pvs.yaml](local-pvs.yaml) contains a `kubernetes.io/no-provisioner`
+StorageClass with `WaitForFirstConsumer` and all seven prebound PVs. It assumes
+release/namespace `hugegraph` and hostname labels `worker-1`, `worker-2`, `worker-3`.
+Change both `claimRef` and nodes if those names differ. StatefulSet claim names
+are `---`; Hubble uses
+`-hubble-data`.
+
+On each indicated host, create these directories on disks with sufficient free
+space and make them writable by the UID/GID of the chosen images (published
+images currently run as root). Existing unrelated data must not be reused.
+
+| Node | Directories | Required capacities |
+|---|---|---|
+| worker-1 | `/var/lib/hugegraph/pd-0`, `/var/lib/hugegraph/store-0`, `/var/lib/hugegraph/hubble` | 10 GiB + 50 GiB + 1 GiB |
+| worker-2 | `/var/lib/hugegraph/pd-1`, `/var/lib/hugegraph/store-1` | 10 GiB + 50 GiB |
+| worker-3 | `/var/lib/hugegraph/pd-2`, `/var/lib/hugegraph/store-2` | 10 GiB + 50 GiB |
+
+PV capacity is a binding declaration, not a directory quota; arrange filesystem
+capacity separately. Check labels with `kubectl get nodes -L kubernetes.io/hostname`,
+then apply the reviewed resources:
+
+```bash
+kubectl create namespace hugegraph
+kubectl apply -f helm/hugegraph/examples/local-pvs.yaml
+helm install hugegraph ./helm/hugegraph -n hugegraph \
+ -f helm/hugegraph/examples/values-local-storage.yaml --wait --timeout 15m
+```
+
+`WaitForFirstConsumer` may leave a PVC Pending until a Pod can be scheduled;
+local PV node affinity and workload affinity/nodeSelector must allow the same
+node. Local volumes survive Pod replacement on that node, but cannot move their
+files to another node on failure. `Retain` keeps data on claim deletion;
+recovery/reuse requires operator inspection.
+
+For a fresh temporary installation, `--set persistence.enabled=false` uses
+`emptyDir`. Component `pd.storage.enabled`, `store.storage.enabled` and
+`hubble.persistence.enabled` override the global choice. A failed PVC never
+falls back to temporary storage. Changing an initialized cluster's storage mode
+requires a separate data migration/recovery procedure.
+
+## Internal images
+
+[values-internal-registry.yaml](values-internal-registry.yaml) mirrors the default
+four images: `hugegraph/pd:latest`, `hugegraph/store:latest`,
+`hugegraph/server:latest`, and `hugegraph/hubble:latest`. Tags follow the chart's
+current version policy; pin your own published tags/digests.
+
+`global.imageRegistry` prefixes repositories without an explicit registry.
+A repository such as `other-registry.example.com/team/pd` overrides that prefix.
+Registry credentials remain `imagePullSecrets`.
+
+The PD wait init container reuses the Store image and Helm test reuses the
+Server image. Both already include `curl`; the chart selects a shell command
+rather than the component entrypoint. No extra `curlimages/curl` image or runtime
+package installation is needed by default. `store.waitImage` and `server.waitImage`
+remain explicit overrides for custom images. Store wait overrides need
+`/bin/sh`, `curl`, `tr`, `date` and `sleep`; Server test overrides additionally
+need `base64`, `getent`, `awk`, `sort`, `grep` and `tail`. Reused component images
+also inherit the component pull policy. Older values may retain a curl image
+under `--reuse-values`; clear those two fields to restore inheritance.
+
+```bash
+helm install hugegraph ./helm/hugegraph -n hugegraph --create-namespace \
+ -f helm/hugegraph/examples/values-internal-registry.yaml --wait --timeout 15m
+```
+
+## GitOps credentials
+
+Use [values-gitops-fixed.yaml](values-gitops-fixed.yaml) with encrypted values,
+or [values-gitops-existing-secrets.yaml](values-gitops-existing-secrets.yaml) with
+a secret controller. Replace sample credentials before use. For the latter,
+provision these three Secret data keys before the workloads start:
+
+| Secret | Key | Purpose |
+|---|---|---|
+| `hugegraph-pd-credentials` | `secret-key` | Shared PD REST Basic-auth password |
+| `hugegraph-admin-credentials` | `password` | Initial Server admin password |
+| `hugegraph-token-credentials` | `secret` | Shared JWT signing key, at least 32 bytes |
+
+The admin password must be printable ASCII without spaces, colons or backslashes.
+PD credentials must be printable ASCII without surrounding whitespace or
+backslashes. `existingSecret` takes priority over fixed values and is not managed
+by the chart. Ordinary Helm installations generate/reuse credentials; repeated
+offline renders cannot look up live generated Secrets and otherwise generate new
+random values. Changing the admin Secret alone does not change an already-created
+account; follow the [account password and Secret synchronization steps](../docs/operations.md#credentials).
+
+Before moving an existing release to a declarative GitOps renderer, export and
+pin its effective values; offline rendering cannot apply Helm's `--reuse-values`.
+Keep the existing `server.service.type`, `hubble.service.type`, `hubble.enabled`
+and storage flags explicitly, as well as fixed/external credentials. Selecting
+`mode: strict` keeps inherited Services internal, but also enables NetworkPolicy;
+set `networkPolicy.enabled` explicitly to preserve the installed behavior.
+A legacy Hubble with no mounted data volume still has metadata in its container
+filesystem: keep `hubble.persistence.enabled: false` until a reviewed migration
+moves that metadata to a PVC. Export or back up any records you need before
+upgrading: keeping the temporary mode does not retain the old container's H2
+files when its Pod is replaced. Do not use a default reset as a migration.
+
+## Entry points and UI
+
+[values-http-ingress.yaml](values-http-ingress.yaml) uses an already installed
+nginx controller for hostless HTTP Server Ingress. Both modes allow HTTP;
+certificates, hostnames and acknowledgement fields are optional.
+NodePort needs reachable node IPs and firewall permissions; query the actual port
+with `kubectl -n hugegraph get svc`, then use `http://:`.
+ClusterIP stays inside the cluster; use a gateway or port-forward for external
+access. LoadBalancer needs your infrastructure's controller.
+
+[values-api-only.yaml](values-api-only.yaml) disables Hubble. Keep auth for an
+authenticated API; disable `server.auth.enabled` as well for an unauthenticated
+API. Current Hubble login still needs Server auth. Strict mode defaults to internal
+Services and NetworkPolicy, but explicit parameters override defaults. With
+NetworkPolicy enabled, add native `extraIngress` rules for actual clients; `{}`
+is a valid allow-all rule.
diff --git a/helm/hugegraph/examples/local-pvs.yaml b/helm/hugegraph/examples/local-pvs.yaml
new file mode 100644
index 0000000000..72721ea9f0
--- /dev/null
+++ b/helm/hugegraph/examples/local-pvs.yaml
@@ -0,0 +1,193 @@
+#
+# 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.
+#
+
+
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: hugegraph-local
+provisioner: kubernetes.io/no-provisioner
+volumeBindingMode: WaitForFirstConsumer
+reclaimPolicy: Retain
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: hugegraph-pd-0
+spec:
+ capacity:
+ storage: 10Gi
+ volumeMode: Filesystem
+ accessModes: [ReadWriteOnce]
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: hugegraph-local
+ claimRef:
+ namespace: hugegraph
+ name: pd-data-hugegraph-pd-0
+ local:
+ path: /var/lib/hugegraph/pd-0
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [worker-1]
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: hugegraph-pd-1
+spec:
+ capacity:
+ storage: 10Gi
+ volumeMode: Filesystem
+ accessModes: [ReadWriteOnce]
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: hugegraph-local
+ claimRef:
+ namespace: hugegraph
+ name: pd-data-hugegraph-pd-1
+ local:
+ path: /var/lib/hugegraph/pd-1
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [worker-2]
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: hugegraph-pd-2
+spec:
+ capacity:
+ storage: 10Gi
+ volumeMode: Filesystem
+ accessModes: [ReadWriteOnce]
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: hugegraph-local
+ claimRef:
+ namespace: hugegraph
+ name: pd-data-hugegraph-pd-2
+ local:
+ path: /var/lib/hugegraph/pd-2
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [worker-3]
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: hugegraph-store-0
+spec:
+ capacity:
+ storage: 50Gi
+ volumeMode: Filesystem
+ accessModes: [ReadWriteOnce]
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: hugegraph-local
+ claimRef:
+ namespace: hugegraph
+ name: store-data-hugegraph-store-0
+ local:
+ path: /var/lib/hugegraph/store-0
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [worker-1]
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: hugegraph-store-1
+spec:
+ capacity:
+ storage: 50Gi
+ volumeMode: Filesystem
+ accessModes: [ReadWriteOnce]
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: hugegraph-local
+ claimRef:
+ namespace: hugegraph
+ name: store-data-hugegraph-store-1
+ local:
+ path: /var/lib/hugegraph/store-1
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [worker-2]
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: hugegraph-store-2
+spec:
+ capacity:
+ storage: 50Gi
+ volumeMode: Filesystem
+ accessModes: [ReadWriteOnce]
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: hugegraph-local
+ claimRef:
+ namespace: hugegraph
+ name: store-data-hugegraph-store-2
+ local:
+ path: /var/lib/hugegraph/store-2
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [worker-3]
+---
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: hugegraph-hubble
+spec:
+ capacity:
+ storage: 1Gi
+ volumeMode: Filesystem
+ accessModes: [ReadWriteOnce]
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: hugegraph-local
+ claimRef:
+ namespace: hugegraph
+ name: hugegraph-hubble-data
+ local:
+ path: /var/lib/hugegraph/hubble
+ nodeAffinity:
+ required:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [worker-1]
diff --git a/helm/hugegraph/examples/values-api-only.yaml b/helm/hugegraph/examples/values-api-only.yaml
new file mode 100644
index 0000000000..9e6c4c5216
--- /dev/null
+++ b/helm/hugegraph/examples/values-api-only.yaml
@@ -0,0 +1,25 @@
+#
+# 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.
+#
+
+
+# Current Hubble login needs Server auth; disable UI for API-only.
+hubble:
+ enabled: false
+# Optional unauthenticated API:
+# server:
+# auth:
+# enabled: false
diff --git a/helm/hugegraph/examples/values-gitops-existing-secrets.yaml b/helm/hugegraph/examples/values-gitops-existing-secrets.yaml
new file mode 100644
index 0000000000..8b5d65d544
--- /dev/null
+++ b/helm/hugegraph/examples/values-gitops-existing-secrets.yaml
@@ -0,0 +1,34 @@
+#
+# 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.
+#
+
+
+# Provision these Secrets before workloads through your secret controller.
+pd:
+ auth:
+ existingSecret: hugegraph-pd-credentials
+ key: secret-key
+ autoGenerate: false
+server:
+ auth:
+ admin:
+ existingSecret: hugegraph-admin-credentials
+ key: password
+ autoGenerate: false
+ token:
+ existingSecret: hugegraph-token-credentials
+ key: secret
+ autoGenerate: false
diff --git a/helm/hugegraph/examples/values-gitops-fixed.yaml b/helm/hugegraph/examples/values-gitops-fixed.yaml
new file mode 100644
index 0000000000..b4c5294054
--- /dev/null
+++ b/helm/hugegraph/examples/values-gitops-fixed.yaml
@@ -0,0 +1,31 @@
+#
+# 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.
+#
+
+
+# Example only: replace with unique credentials in encrypted values.
+pd:
+ auth:
+ value: REPLACE_WITH_PD_REST_SECRET
+ autoGenerate: false
+server:
+ auth:
+ admin:
+ password: REPLACE_WITH_INITIAL_ADMIN_PASSWORD
+ autoGenerate: false
+ token:
+ value: REPLACE_WITH_UNIQUE_JWT_SIGNING_KEY_AT_LEAST_32_BYTES
+ autoGenerate: false
diff --git a/helm/hugegraph/examples/values-http-ingress.yaml b/helm/hugegraph/examples/values-http-ingress.yaml
new file mode 100644
index 0000000000..f459bb2866
--- /dev/null
+++ b/helm/hugegraph/examples/values-http-ingress.yaml
@@ -0,0 +1,36 @@
+#
+# 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.
+#
+
+
+# Requires an installed Ingress controller; host/TLS are optional.
+server:
+ service:
+ type: ClusterIP
+ ingress:
+ enabled: true
+ className: nginx
+ hosts:
+ - paths:
+ - path: /
+ pathType: Prefix
+ tls: []
+hubble:
+ service:
+ type: ClusterIP
+ ingress:
+ enabled: false
+# Use separate hosts/paths to expose Hubble through the same controller.
diff --git a/helm/hugegraph/examples/values-internal-registry.yaml b/helm/hugegraph/examples/values-internal-registry.yaml
new file mode 100644
index 0000000000..65c6301f34
--- /dev/null
+++ b/helm/hugegraph/examples/values-internal-registry.yaml
@@ -0,0 +1,32 @@
+#
+# 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.
+#
+
+
+# Mirror hugegraph/pd:latest, hugegraph/store:latest,
+# hugegraph/server:latest, hugegraph/hubble:latest at your private registry.
+global:
+ imageRegistry: registry.internal.example.com
+store:
+ waitImage: ""
+server:
+ waitImage: ""
+# Qualified component repository overrides the global prefix:
+# pd:
+# image:
+# repository: other-registry.example.com/team/pd
+# imagePullSecrets:
+# - name: registry-credentials
diff --git a/helm/hugegraph/examples/values-local-storage.yaml b/helm/hugegraph/examples/values-local-storage.yaml
new file mode 100644
index 0000000000..a6a31a9f7e
--- /dev/null
+++ b/helm/hugegraph/examples/values-local-storage.yaml
@@ -0,0 +1,29 @@
+#
+# 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.
+#
+
+
+# Apply local-pvs.yaml after preparing the host directories.
+# Release/namespace must match the example PV claimRef: hugegraph.
+pd:
+ storage:
+ storageClassName: hugegraph-local
+store:
+ storage:
+ storageClassName: hugegraph-local
+hubble:
+ persistence:
+ storageClassName: hugegraph-local
diff --git a/helm/hugegraph/templates/NOTES.txt b/helm/hugegraph/templates/NOTES.txt
new file mode 100644
index 0000000000..10892ba8ef
--- /dev/null
+++ b/helm/hugegraph/templates/NOTES.txt
@@ -0,0 +1,167 @@
+{{/*
+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.
+*/}}
+{{ include "hugegraph.resolveValues" . }}
+{{- $hubbleEnabled := .Values.hubble.enabled }}
+{{ .Release.Name }} installed in namespace {{ .Release.Namespace }} ({{ .Values.mode | default "legacy" }} mode).
+
+ PD {{ .Values.pd.replicas }} replica(s)
+ Store {{ .Values.store.replicas }} replica(s)
+ Server {{ if .Values.server.hpa.enabled }}HPA {{ .Values.server.hpa.minReplicas }}-{{ .Values.server.hpa.maxReplicas }}{{ else }}{{ .Values.server.replicas }} replica(s){{ end }}
+{{- if $hubbleEnabled }}
+ Hubble 1 replica (UI)
+{{- end }}
+{{- $inheritedEntries := list }}
+{{- range $component := list "server" "hubble" }}
+{{- $cfg := get $.Values $component }}
+{{- if and (or (ne $component "hubble") $hubbleEnabled) (get $cfg "__serviceTypeInherited") (eq $cfg.service.type "NodePort") }}
+{{- $inheritedEntries = append $inheritedEntries ($component | title) }}
+{{- end }}
+{{- end }}
+{{- if and .Release.IsUpgrade (eq (.Values.mode | default "legacy") "easy") $inheritedEntries }}
+
+Upgrade notice: chart 0.2.0 changed entry defaults. This easy-mode upgrade inherits NodePort for {{ join ", " $inheritedEntries }}.
+For releases installed before 0.2.0, review helm/hugegraph/docs/operations.md#storage-and-upgrades.
+Use --reuse-values to retain previous settings, or explicitly set the desired Service types and Hubble setting.
+{{- end }}
+
+Watch startup (run each command in a separate terminal):
+
+ kubectl get pods -n {{ .Release.Namespace }} -l app.kubernetes.io/instance={{ .Release.Name }} -w
+ kubectl get pvc -n {{ .Release.Namespace }} -w
+{{- $claims := 0 }}
+{{- if .Values.pd.storage.enabled }}{{ $claims = add $claims (int .Values.pd.replicas) }}{{ end }}
+{{- if .Values.store.storage.enabled }}{{ $claims = add $claims (int .Values.store.replicas) }}{{ end }}
+{{- if and $hubbleEnabled .Values.hubble.persistence.enabled }}{{ $claims = add $claims 1 }}{{ end }}
+{{- if gt (int $claims) 0 }}
+
+This configuration needs {{ $claims }} persistent volume claim(s). A dynamic
+StorageClass or matching pre-created PVs must be available; see helm/hugegraph/examples/README.md#storage.
+{{- end }}
+
+Store waits for a PD quorum before startup. If a Pod remains in Init, inspect
+its init-container logs and PD readiness; see helm/hugegraph/docs/operations.md#troubleshooting.
+
+Access:
+{{- range $component := list "server" "hubble" "pd" }}
+{{- if or (ne $component "hubble") $hubbleEnabled }}
+{{- $cfg := get $.Values $component }}
+{{- $name := include (printf "hugegraph.%s.name" $component) $ }}
+{{- if eq $component "pd" }}{{ $name = include "hugegraph.pd.clientName" $ }}{{ end }}
+{{- $portName := "http" }}
+{{- $port := get $cfg "port" }}
+{{- if eq $component "pd" }}{{ $portName = "rest" }}{{ $port = $cfg.ports.rest }}{{ end }}
+{{- $svc := $cfg.service }}
+
+ {{ $component | title }}:
+{{- if eq $svc.type "NodePort" }}
+ kubectl get svc -n {{ $.Release.Namespace }} {{ $name }} -o jsonpath='{.spec.ports[?(@.name=="{{ $portName }}")].nodePort}{"\n"}'
+ kubectl get nodes -o wide
+ Open http://:{{ if eq $component "server" }}/versions{{ end }}
+{{- else if eq $svc.type "LoadBalancer" }}
+ kubectl get svc -n {{ $.Release.Namespace }} {{ $name }} -o wide
+ kubectl get svc -n {{ $.Release.Namespace }} {{ $name }} -o jsonpath='{.status.loadBalancer.ingress}{"\n"}'
+ Once an address is assigned, use http://:{{ $port }}{{ if eq $component "server" }}/versions{{ end }}
+{{- else }}
+ In-cluster address: http://{{ $name }}.{{ $.Release.Namespace }}.svc:{{ $port }}
+ ClusterIP is reachable inside the cluster; use port-forward below from your workstation.
+{{- end }}
+{{- $ingress := get $cfg "ingress" | default dict }}
+{{- if get $ingress "enabled" }}
+ Ingress address/status: kubectl get ingress -n {{ $.Release.Namespace }} {{ $name }} -o wide
+{{- $tlsHosts := list }}
+{{- range (get $ingress "tls" | default list) }}
+{{- range (.hosts | default list) }}{{ $tlsHosts = append $tlsHosts . }}{{ end }}
+{{- end }}
+{{- range (get $ingress "hosts" | default (list (dict "host" ""))) }}
+{{- $host := .host | default "" }}
+{{- $scheme := "http" }}
+{{- if or (has $host $tlsHosts) (and (empty $host) (not (empty (get $ingress "tls")))) }}{{ $scheme = "https" }}{{ end }}
+ {{ $scheme }}://{{ $host | default "" }}{{ (first (.paths | default (list (dict "path" "/")))).path | default "/" }}
+{{- end }}
+{{- end }}
+ Port-forward: kubectl port-forward -n {{ $.Release.Namespace }} svc/{{ $name }} {{ $port }}:{{ $port }}
+ Local address: http://127.0.0.1:{{ $port }}
+{{- end }}
+{{- end }}
+
+Credentials:
+
+ PD_SECRET="$(kubectl get secret -n {{ .Release.Namespace }} {{ include "hugegraph.pd.authSecretName" . }} -o jsonpath='{.data.{{ include "hugegraph.pd.authSecretKey" . | replace "." "\\." }}}' | base64 --decode)"
+{{- if .Values.server.auth.enabled }}
+ PASSWORD="$(kubectl get secret -n {{ .Release.Namespace }} {{ include "hugegraph.server.authSecretName" . }} -o jsonpath='{.data.{{ include "hugegraph.server.authSecretKey" . | replace "." "\\." }}}' | base64 --decode)"
+ curl --user "admin:${PASSWORD}" http:///versions
+
+The admin Secret contains the initial password. Changing it does not reset an
+existing account; see helm/hugegraph/docs/operations.md#credentials for account password changes and Secret synchronization.
+{{- else }}
+ Server authentication is disabled.
+{{- end }}
+{{- if and $hubbleEnabled (not .Values.server.auth.enabled) }}
+ Current Hubble images require Server authentication to complete login.
+ Enable server.auth.enabled or set hubble.enabled=false for API-only deployment.
+{{- end }}
+{{- if and $hubbleEnabled (not .Values.hubble.persistence.enabled) }}
+ Hubble connection metadata uses temporary storage and is lost on Pod replacement.
+{{- end }}
+{{- $pdAuth := .Values.pd.auth }}
+{{- $srvAuth := .Values.server.auth }}
+{{- $generated := list }}
+{{- if and (not $pdAuth.existingSecret) (not $pdAuth.value) }}{{ $generated = append $generated "pd.auth" }}{{ end }}
+{{- if and $srvAuth.enabled (not $srvAuth.admin.existingSecret) (not $srvAuth.admin.password) }}{{ $generated = append $generated "server.auth.admin" }}{{ end }}
+{{- if and $srvAuth.enabled (not $srvAuth.token.existingSecret) (not $srvAuth.token.value) }}{{ $generated = append $generated "server.auth.token" }}{{ end }}
+{{- if $generated }}
+
+Chart-generated credentials are in use: {{ join ", " $generated }}.
+For GitOps/template-only pipelines use fixed credentials or existingSecret;
+see helm/hugegraph/examples/README.md#gitops-credentials.
+{{- end }}
+{{- if .Values.networkPolicy.enabled }}
+
+NetworkPolicy is enabled. Add application or monitoring clients through
+networkPolicy..extraIngress; enforcement depends on the cluster network plugin.
+{{- range $component := list "server" "hubble" "pd" }}
+{{- if or (ne $component "hubble") $hubbleEnabled }}
+{{- $cfg := get $.Values $component }}
+{{- $ingress := get $cfg "ingress" | default dict }}
+{{- $exposed := or (has $cfg.service.type (list "NodePort" "LoadBalancer")) (get $ingress "enabled" | default false) (not (empty (get $cfg.service "externalIPs"))) }}
+{{- $policy := get $.Values.networkPolicy $component | default dict }}
+{{- if and $exposed (empty (get $policy "extraIngress")) }}
+ {{ $component | title }} has an external entry, but NetworkPolicy has no external ingress rule.
+ Add networkPolicy.{{ $component }}.extraIngress for the client/controller, or disable networkPolicy.enabled.
+ An explicit [{}] ingress rule allows all sources and ports; choose the rule for your network.
+{{- end }}
+{{- end }}
+{{- end }}
+{{- $hubblePolicy := get $.Values.networkPolicy "hubble" | default dict }}
+{{- if and $.Values.server.advertiseUrl (empty (get $hubblePolicy "extraEgress")) $hubbleEnabled (eq $.Values.hubble.mode "pd") }}
+ For an external server.advertiseUrl, also add networkPolicy.hubble.extraEgress for that address.
+{{- end }}
+{{- end }}
+{{- if .Values.server.advertiseUrl }}
+
+Server registers {{ .Values.server.advertiseUrl }} with PD for external discovery.
+A standalone Hubble also needs a reachable PD endpoint; see helm/hugegraph/docs/operations.md#networking-and-hubble.
+{{- end }}
+
+Verify all Server Gremlin endpoints:
+
+ helm test {{ .Release.Name }} --namespace {{ .Release.Namespace }} --logs --timeout 5m
+
+Run this after upgrades too. Ready probes alone do not verify Gremlin binding.
+{{- if $hubbleEnabled }}
+Also verify a fresh Hubble login and graph query; the hook checks Server, not UI login.
+{{- end }}
diff --git a/helm/hugegraph/templates/_helpers.tpl b/helm/hugegraph/templates/_helpers.tpl
new file mode 100644
index 0000000000..e31813e60e
--- /dev/null
+++ b/helm/hugegraph/templates/_helpers.tpl
@@ -0,0 +1,1079 @@
+#
+# 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.
+#
+
+{{/* Resolve inherited defaults once. Never use default/or for explicit booleans. */}}
+{{- define "hugegraph.resolveValues" -}}
+{{- if not (get .Values "__hugegraphResolved") -}}
+{{- $_ := set .Values "__hugegraphResolved" true -}}
+{{- $legacy := not (hasKey .Values "mode") -}}
+{{- if not (hasKey .Values "hubble") -}}
+{{- $_ := set .Values "hubble" (dict "enabled" false "service" dict "persistence" dict) -}}
+{{- end -}}
+{{- $strict := eq (.Values.mode | default "easy") "strict" -}}
+{{- $serviceType := ternary "ClusterIP" "NodePort" (or $strict $legacy) -}}
+{{- range $comp := list "server" "hubble" -}}
+{{- $component := get $.Values $comp -}}
+{{- $svc := get $component "service" | default dict -}}
+{{- $_ := set $component "service" $svc -}}
+{{- $typeInherited := or (not (hasKey $svc "type")) (eq (get $svc "type" | toString) "") -}}
+{{- $_ := set $component "__serviceTypeInherited" $typeInherited -}}
+{{- if $typeInherited -}}{{- $_ := set $svc "type" $serviceType -}}{{- end -}}
+{{- end -}}
+{{- $np := .Values.networkPolicy | default dict -}}
+{{- $_ := set .Values "networkPolicy" $np -}}
+{{- if or (not (hasKey $np "enabled")) (eq (get $np "enabled" | toString) "") -}}{{- $_ := set $np "enabled" $strict -}}{{- end -}}
+{{- $persistent := true -}}
+{{- if hasKey (.Values.persistence | default dict) "enabled" -}}{{- $persistent = .Values.persistence.enabled -}}{{- end -}}
+{{- range $comp := list "pd" "store" "hubble" -}}
+{{- $component := get $.Values $comp -}}
+{{- $key := ternary "persistence" "storage" (eq $comp "hubble") -}}
+{{- $storage := get $component $key | default dict -}}
+{{- $_ := set $component $key $storage -}}
+{{- if or (not (hasKey $storage "enabled")) (eq (get $storage "enabled" | toString) "") -}}{{- $_ := set $storage "enabled" $persistent -}}{{- end -}}
+{{- end -}}
+{{- $registry := trimSuffix "/" (get (.Values.global | default dict) "imageRegistry" | default "") -}}
+{{- if contains "://" $registry -}}{{- fail "global.imageRegistry must be a registry host/path without http:// or https://" -}}{{- end -}}
+{{- if $registry -}}
+{{- range $comp := list "pd" "store" "server" "hubble" -}}
+{{- $image := get (get $.Values $comp) "image" | default dict -}}
+{{- if $image -}}
+{{- $_ := set $image "repository" (include "hugegraph.registryImage" (dict "reference" $image.repository "registry" $registry)) -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+{{- range $comp := list "store" "server" -}}
+{{- $values := get $.Values $comp -}}
+{{- $override := get $values "waitImage" | default "" -}}
+{{- $componentImage := include "hugegraph.image" (dict "image" $values.image "appVersion" $.Chart.AppVersion) -}}
+{{- if $override -}}
+{{- $_ := set $values "waitImage" (include "hugegraph.registryImage" (dict "reference" $override "registry" $registry)) -}}
+{{- else -}}
+{{- $_ := set $values "waitImage" $componentImage -}}
+{{- end -}}
+{{- $_ := set $values "__waitImageInherited" (eq $values.waitImage $componentImage) -}}
+{{- end -}}
+{{- $server := .Values.server -}}
+{{- if not (hasKey $server "storageWaitTimeoutSeconds") -}}{{- $_ := set $server "storageWaitTimeoutSeconds" 300 -}}{{- end -}}
+{{- $startup := $server.probes.startup -}}
+{{- if $legacy -}}
+{{- $period := int $startup.periodSeconds -}}
+{{- $budget := add (int $server.storageWaitTimeoutSeconds) 150 -}}
+{{- $_ := set $startup "failureThreshold" (max (int $startup.failureThreshold) (add (div (add (sub $budget 1) $period) $period) 1)) -}}
+{{- end -}}
+{{- if or (not (hasKey $startup "failureThreshold")) (eq (get $startup "failureThreshold" | toString) "") -}}
+{{- $period := int $startup.periodSeconds -}}
+{{- $budget := add (int $server.storageWaitTimeoutSeconds) 150 -}}
+{{- $_ := set $startup "failureThreshold" (add (div (add (sub $budget 1) $period) $period) 1) -}}
+{{- end -}}
+{{- end -}}
+{{- end }}
+
+{{/* A qualified component reference overrides the global registry. */}}
+{{- define "hugegraph.registryImage" -}}
+{{- $reference := .reference -}}
+{{- $first := first (splitList "/" $reference) -}}
+{{- $qualified := and (contains "/" $reference) (or (contains "." $first) (contains ":" $first) (eq $first "localhost")) -}}
+{{- if and .registry (not $qualified) -}}{{- printf "%s/%s" .registry $reference -}}{{- else -}}{{- $reference -}}{{- end -}}
+{{- end }}
+
+{{/*
+Keep the application identity stable when the distribution chart is renamed.
+The OCI artifact is hugegraph-helm; Kubernetes resources still use hugegraph.
+*/}}
+{{- define "hugegraph.name" -}}
+{{- default "hugegraph" .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+*/}}
+{{- define "hugegraph.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default "hugegraph" .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{- define "hugegraph.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{- define "hugegraph.labels" -}}
+helm.sh/chart: {{ include "hugegraph.chart" . }}
+{{ include "hugegraph.selectorLabels" . }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{- define "hugegraph.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "hugegraph.name" . | quote }}
+app.kubernetes.io/instance: {{ .Release.Name | quote }}
+{{- end }}
+
+{{- define "hugegraph.pd.name" -}}
+{{- printf "%s-pd" (include "hugegraph.fullname" . | trunc 57 | trimSuffix "-") }}
+{{- end }}
+
+{{- define "hugegraph.pd.clientName" -}}
+{{- printf "%s-pd-client" (include "hugegraph.fullname" . | trunc 53 | trimSuffix "-") }}
+{{- end }}
+
+{{- define "hugegraph.store.name" -}}
+{{- printf "%s-store" (include "hugegraph.fullname" . | trunc 54 | trimSuffix "-") }}
+{{- end }}
+
+{{- define "hugegraph.server.name" -}}
+{{- printf "%s-server" (include "hugegraph.fullname" . | trunc 56 | trimSuffix "-") }}
+{{- end }}
+
+{{- define "hugegraph.server.headlessName" -}}
+{{- printf "%s-server-headless" (include "hugegraph.fullname" . | trunc 47 | trimSuffix "-") }}
+{{- end }}
+
+{{- define "hugegraph.hubble.name" -}}
+{{- printf "%s-hubble" (include "hugegraph.fullname" . | trunc 56 | trimSuffix "-") }}
+{{- end }}
+
+{{- define "hugegraph.hubble.dataName" -}}
+{{- printf "%s-hubble-data" (include "hugegraph.fullname" . | trunc 51 | trimSuffix "-") }}
+{{- end }}
+
+{{- define "hugegraph.test.name" -}}
+{{- printf "%s-test-connection" (include "hugegraph.fullname" . | trunc 47 | trimSuffix "-") }}
+{{- end }}
+
+{{/*
+Resolve the Server authentication Secret. A user-provided Secret always wins;
+otherwise use a stable chart-managed name so the generated Secret can survive
+uninstall and be reused by a later install of the same release.
+*/}}
+{{- define "hugegraph.server.authSecretName" -}}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $admin := get $auth "admin" | default dict -}}
+{{- $existingSecret := get $admin "existingSecret" | default "" -}}
+{{- if $existingSecret -}}
+{{- $existingSecret -}}
+{{- else -}}
+{{- printf "%s-admin" (.Release.Name | trunc 55 | trimSuffix "-") -}}
+{{- end -}}
+{{- end }}
+
+{{- define "hugegraph.server.authSecretKey" -}}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $admin := get $auth "admin" | default dict -}}
+{{- get $admin "key" | default "password" -}}
+{{- end }}
+
+{{/*
+Return the chart-managed admin password (base64). Inline admin.password wins
+on first write; otherwise lookup keeps upgrades from rotating a generated
+credential. Never used for an external existingSecret.
+*/}}
+{{- define "hugegraph.server.authSecretPassword" -}}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $admin := get $auth "admin" | default dict -}}
+{{- $password := get $admin "password" | default "" -}}
+{{- if $password -}}
+{{- $password | b64enc -}}
+{{- else -}}
+{{- $key := include "hugegraph.server.authSecretKey" . -}}
+{{- $secret := lookup "v1" "Secret" .Release.Namespace (include "hugegraph.server.authSecretName" .) -}}
+{{- if and $secret (hasKey $secret "data") (hasKey (get $secret "data") $key) -}}
+{{- get (get $secret "data") $key -}}
+{{- else -}}
+{{- include "hugegraph.generatedCredential" (dict "root" . "name" "server-admin") | b64enc -}}
+{{- end -}}
+{{- end -}}
+{{- end }}
+
+{{/*
+One random credential per render, by name. randAlphaNum returns a different
+value on every call, and the Secret template and the rollout checksum both
+need the value the install is about to write, so the first call generates it
+and later calls read it back from a scratch map kept on .Values for the
+duration of this render (it is not part of the stored release values; the
+next render finds the Secret through lookup instead).
+*/}}
+{{- define "hugegraph.generatedCredential" -}}
+{{- if not (hasKey .root.Values "__generatedCredentials") -}}{{- $_ := set .root.Values "__generatedCredentials" dict -}}{{- end -}}
+{{- $memo := get .root.Values "__generatedCredentials" -}}
+{{- if not (hasKey $memo .name) -}}{{- $_ := set $memo .name (randAlphaNum 32) -}}{{- end -}}
+{{- get $memo .name -}}
+{{- end }}
+
+{{/*
+Resolve the JWT signing Secret. User-provided token.existingSecret wins;
+otherwise use a stable chart-managed name so every Server replica shares one key.
+*/}}
+{{- define "hugegraph.server.authTokenSecretName" -}}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $token := get $auth "token" | default dict -}}
+{{- $existing := get $token "existingSecret" | default "" -}}
+{{- if $existing -}}
+{{- $existing -}}
+{{- else -}}
+{{- printf "%s-auth-token" (.Release.Name | trunc 51 | trimSuffix "-") -}}
+{{- end -}}
+{{- end }}
+
+{{- define "hugegraph.server.authTokenSecretKey" -}}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $token := get $auth "token" | default dict -}}
+{{- get $token "key" | default "token_secret" -}}
+{{- end }}
+
+{{/*
+Return the chart-managed JWT signing secret (base64). Inline token.value wins
+on first write; otherwise lookup keeps multi-replica pods and upgrades on the
+same signing key.
+*/}}
+{{- define "hugegraph.server.authTokenSecretValue" -}}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $token := get $auth "token" | default dict -}}
+{{- $value := get $token "value" | default "" -}}
+{{- if $value -}}
+{{- $value | b64enc -}}
+{{- else -}}
+{{- $name := include "hugegraph.server.authTokenSecretName" . -}}
+{{- $key := include "hugegraph.server.authTokenSecretKey" . -}}
+{{- $secret := lookup "v1" "Secret" .Release.Namespace $name -}}
+{{- if and $secret (hasKey $secret "data") (hasKey (get $secret "data") $key) -}}
+{{- get (get $secret "data") $key -}}
+{{- else -}}
+{{- include "hugegraph.generatedCredential" (dict "root" . "name" "server-token") | b64enc -}}
+{{- end -}}
+{{- end -}}
+{{- end }}
+
+{{/*
+Resolve the PD REST auth Secret. User-provided pd.auth.existingSecret wins;
+otherwise a stable chart-managed name shared by PD, the Server storage wait
+and Hubble.
+*/}}
+{{- define "hugegraph.pd.authSecretName" -}}
+{{- $auth := get .Values.pd "auth" | default dict -}}
+{{- $existing := get $auth "existingSecret" | default "" -}}
+{{- if $existing -}}
+{{- $existing -}}
+{{- else -}}
+{{- printf "%s-pd-auth" (.Release.Name | trunc 55 | trimSuffix "-") -}}
+{{- end -}}
+{{- end }}
+
+{{- define "hugegraph.pd.authSecretKey" -}}
+{{- $auth := get .Values.pd "auth" | default dict -}}
+{{- get $auth "key" | default "secret-key" -}}
+{{- end }}
+
+{{/*
+Return the chart-managed PD REST secret (base64). Inline pd.auth.value wins
+on first write; otherwise lookup keeps every PD, Server and Hubble Pod, and
+every upgrade, on the same secret.
+*/}}
+{{- define "hugegraph.pd.authSecretValue" -}}
+{{- $auth := get .Values.pd "auth" | default dict -}}
+{{- $value := get $auth "value" | default "" -}}
+{{- if $value -}}
+{{- $value | b64enc -}}
+{{- else -}}
+{{- $name := include "hugegraph.pd.authSecretName" . -}}
+{{- $key := include "hugegraph.pd.authSecretKey" . -}}
+{{- $secret := lookup "v1" "Secret" .Release.Namespace $name -}}
+{{- if and $secret (hasKey $secret "data") (hasKey (get $secret "data") $key) -}}
+{{- get (get $secret "data") $key -}}
+{{- else -}}
+{{- include "hugegraph.generatedCredential" (dict "root" . "name" "pd-auth") | b64enc -}}
+{{- end -}}
+{{- end -}}
+{{- end }}
+
+{{/*
+Checksum for the PD, Server and Hubble pod templates so rotating the PD REST
+Secret rolls the Pods that read it. It hashes the Secret name, its key, and
+one revision input chosen by where the credential comes from.
+
+An active inline value is known at render time, so its digest is the
+revision: it changes exactly once, on the upgrade that rotates it. Mixing the
+live resourceVersion into that case would roll the Pods a second time on the
+next no-change upgrade, once the rotated Secret had been applied and its
+resourceVersion moved.
+
+A chart-generated Secret contributes the digest of its value. At install
+that is the value the Secret template is about to write (generated once per
+render, see hugegraph.generatedCredential); on every later render it is the
+live Secret's data, so the first no-change upgrade after an install renders
+the same checksum and rolls nothing. The value is a 32-character random, so
+its digest in a Pod annotation discloses nothing usable. The alternative, the
+live resourceVersion, differs between install (no Secret yet) and the first
+upgrade, which rolled PD and Server together on a no-change upgrade and let
+a Server start against a PD member mid-restart (apache/hugegraph#3228).
+
+An operator-supplied existingSecret keeps the live resourceVersion as its
+revision: its value may be weak enough for a digest to be worth attacking,
+and that Secret exists before the install, so install and upgrade already
+agree. Template-only renders (helm template, GitOps renderers) see no live
+Secret and generate a fresh value on every render; they are unsupported
+with chart-generated credentials, see README, Template-only pipelines.
+*/}}
+{{- define "hugegraph.pd.authChecksum" -}}
+{{- $parts := list (include "hugegraph.pd.authSecretName" .) (include "hugegraph.pd.authSecretKey" .) -}}
+{{- $pdAuthCfg := get .Values.pd "auth" | default dict -}}
+{{- $inline := get $pdAuthCfg "value" | default "" -}}
+{{- $existing := get $pdAuthCfg "existingSecret" | default "" -}}
+{{- if $existing -}}
+{{- $secret := lookup "v1" "Secret" .Release.Namespace $existing -}}
+{{- if $secret -}}{{- $parts = append $parts (dig "metadata" "resourceVersion" "" $secret) -}}{{- end -}}
+{{- else if $inline -}}
+{{- $parts = append $parts (sha256sum $inline) -}}
+{{- else -}}
+{{- $parts = append $parts (include "hugegraph.pd.authSecretValue" . | b64dec | sha256sum) -}}
+{{- end -}}
+{{- join "|" $parts | sha256sum -}}
+{{- end }}
+
+{{/*
+Path the PD startup and liveness probes hit.
+
+/v1/health stays 200 during elections and quorum loss, and images with the
+apache/hugegraph#3222 fix answer 503 on fatal raft errors or shutdown. Losing
+leadership alone must not restart followers and turn an election into a
+rolling outage. Readiness carries the leader-availability signal instead.
+
+A single PD has no election to lose. There, a PD that steps down and cannot
+recover, as after a failed snapshot on a full disk
+(apache/hugegraph#3222), keeps answering /v1/health forever on older images.
+For compatibility with those images, the default moves to /v1/ready when
+pd.replicas is 1.
+
+The startup probe follows this path as well. Kubernetes suppresses liveness
+until the startup probe succeeds, so leaving startup on /v1/health would give
+a single PD only the liveness budget (60 s by default) to reach raft
+readiness after a restart, and a slow log replay would crash-loop instead of
+booting. Following the same path puts that window inside the startup budget
+(300 s by default) instead.
+
+Setting pd.livenessPath overrides the choice in both places. Drop the
+single-PD readiness derivation once the minimum supported image includes
+the /v1/health fatal raft error fix.
+*/}}
+{{- define "hugegraph.pd.livenessPath" -}}
+{{- $explicit := get .Values.pd "livenessPath" | default "" -}}
+{{- if $explicit -}}
+{{- $explicit -}}
+{{- else if eq (int .Values.pd.replicas) 1 -}}
+/v1/ready
+{{- else -}}
+/v1/health
+{{- end -}}
+{{- end }}
+
+{{/*
+PD Raft peers list: pod-0.svc.ns.svc:8610,...
+Uses short headless DNS (cluster.local optional) resolvable inside the namespace.
+*/}}
+{{- define "hugegraph.pd.raftPeersList" -}}
+{{- $peers := list -}}
+{{- $replicas := int .Values.pd.replicas -}}
+{{- $name := include "hugegraph.pd.name" . -}}
+{{- $ns := .Release.Namespace -}}
+{{- $port := int .Values.pd.ports.raft -}}
+{{- range $i := until $replicas -}}
+ {{- $peers = append $peers (printf "%s-%d.%s.%s.svc:%d" $name $i $name $ns $port) -}}
+{{- end -}}
+{{- join "," $peers -}}
+{{- end }}
+
+{{/*
+PD gRPC peers for Store/Server.
+*/}}
+{{- define "hugegraph.pd.grpcPeersList" -}}
+{{- $peers := list -}}
+{{- $replicas := int .Values.pd.replicas -}}
+{{- $name := include "hugegraph.pd.name" . -}}
+{{- $ns := .Release.Namespace -}}
+{{- $port := int .Values.pd.ports.grpc -}}
+{{- range $i := until $replicas -}}
+ {{- $peers = append $peers (printf "%s-%d.%s.%s.svc:%d" $name $i $name $ns $port) -}}
+{{- end -}}
+{{- join "," $peers -}}
+{{- end }}
+
+{{/*
+PD REST endpoints for Server storage-readiness checks.
+*/}}
+{{- define "hugegraph.pd.restPeersList" -}}
+{{- $peers := list -}}
+{{- $replicas := int .Values.pd.replicas -}}
+{{- $name := include "hugegraph.pd.name" . -}}
+{{- $ns := .Release.Namespace -}}
+{{- $port := int .Values.pd.ports.rest -}}
+{{- range $i := until $replicas -}}
+ {{- $peers = append $peers (printf "%s-%d.%s.%s.svc:%d" $name $i $name $ns $port) -}}
+{{- end -}}
+{{- join "," $peers -}}
+{{- end }}
+
+{{/*
+Checksum for the Server pod template so rotating the referenced auth Secrets
+rolls Server pods. The admin and token credentials pick their revision input
+independently, by source, for the reasons given on hugegraph.pd.authChecksum:
+an active inline value and a chart-generated Secret contribute the digest of
+their value (so a rotation rolls Server exactly once, and the first no-change
+upgrade after an install rolls nothing); an operator-supplied existingSecret
+contributes its live metadata.resourceVersion, and its out-of-band rotation
+applies on the next `helm upgrade`. Template-only renders are unsupported
+with chart-generated credentials, see README, Template-only pipelines.
+*/}}
+{{- define "hugegraph.server.authChecksum" -}}
+{{- $parts := list (include "hugegraph.server.authSecretName" .) (include "hugegraph.server.authSecretKey" .) (include "hugegraph.server.authTokenSecretName" .) (include "hugegraph.server.authTokenSecretKey" .) -}}
+{{- $srvAuth := get .Values.server "auth" | default dict -}}
+{{- $adminCfg := get $srvAuth "admin" | default dict -}}
+{{- $inlineAdmin := get $adminCfg "password" | default "" -}}
+{{- $existingAdmin := get $adminCfg "existingSecret" | default "" -}}
+{{- $tokenCfg := get $srvAuth "token" | default dict -}}
+{{- $inlineToken := get $tokenCfg "value" | default "" -}}
+{{- $existingToken := get $tokenCfg "existingSecret" | default "" -}}
+{{- if $existingAdmin -}}
+{{- $admin := lookup "v1" "Secret" .Release.Namespace $existingAdmin -}}
+{{- if $admin -}}{{- $parts = append $parts (dig "metadata" "resourceVersion" "" $admin) -}}{{- end -}}
+{{- else if $inlineAdmin -}}
+{{- $parts = append $parts (sha256sum $inlineAdmin) -}}
+{{- else -}}
+{{- $parts = append $parts (include "hugegraph.server.authSecretPassword" . | b64dec | sha256sum) -}}
+{{- end -}}
+{{- if $existingToken -}}
+{{- $token := lookup "v1" "Secret" .Release.Namespace $existingToken -}}
+{{- if $token -}}{{- $parts = append $parts (dig "metadata" "resourceVersion" "" $token) -}}{{- end -}}
+{{- else if $inlineToken -}}
+{{- $parts = append $parts (sha256sum $inlineToken) -}}
+{{- else -}}
+{{- $parts = append $parts (include "hugegraph.server.authTokenSecretValue" . | b64dec | sha256sum) -}}
+{{- end -}}
+{{- join "|" $parts | sha256sum -}}
+{{- end }}
+
+{{/*
+Initial store list for PD bootstrap: store-0.svc.ns.svc:8500,...
+*/}}
+{{- define "hugegraph.store.initialStoreList" -}}
+{{- $peers := list -}}
+{{- $replicas := int .Values.store.replicas -}}
+{{- $name := include "hugegraph.store.name" . -}}
+{{- $ns := .Release.Namespace -}}
+{{- $port := int .Values.store.ports.grpc -}}
+{{- range $i := until $replicas -}}
+ {{- $peers = append $peers (printf "%s-%d.%s.%s.svc:%d" $name $i $name $ns $port) -}}
+{{- end -}}
+{{- join "," $peers -}}
+{{- end }}
+
+{{/*
+First store REST endpoint for STORE_REST / wait-partition.
+*/}}
+{{- define "hugegraph.store.restPrimary" -}}
+{{- $name := include "hugegraph.store.name" . -}}
+{{- $ns := .Release.Namespace -}}
+{{- printf "%s-0.%s.%s.svc:%d" $name $name $ns (int .Values.store.ports.rest) -}}
+{{- end }}
+
+{{/*
+Server REST URL reached through the client Service.
+*/}}
+{{- define "hugegraph.server.clientUrl" -}}
+{{- printf "http://%s.%s.svc:%d" (include "hugegraph.server.name" .) .Release.Namespace (int .Values.server.port) -}}
+{{- end }}
+
+{{/*
+URL registered with PD (server.urls_to_pd / HG_SERVER_URLS_TO_PD).
+server.advertiseUrl wins when set so outside PD-mode Hubble receives a reachable address; otherwise each Server Pod announces its own Pod IP so PD discovery preserves the replica list for in-cluster clients.
+*/}}
+{{- define "hugegraph.server.urlsToPd" -}}
+{{- $advertise := trim (default "" .Values.server.advertiseUrl) -}}
+{{- if $advertise -}}
+{{- $advertise -}}
+{{- else -}}
+{{- printf "http://$(POD_IP):%d" (int .Values.server.port) -}}
+{{- end -}}
+{{- end }}
+
+{{/*
+PD REST endpoint reached through the client Service, for Hubble's pd.server.
+*/}}
+{{- define "hugegraph.pd.restClientEndpoint" -}}
+{{- printf "%s.%s.svc:%d" (include "hugegraph.pd.clientName" .) .Release.Namespace (int .Values.pd.ports.rest) -}}
+{{- end }}
+
+{{/*
+Store REST origins in Hubble's bracketed allow-list form:
+[http://store-0.svc.ns.svc:8520,...]
+*/}}
+{{- define "hugegraph.store.restOriginsList" -}}
+{{- $origins := list -}}
+{{- $replicas := int .Values.store.replicas -}}
+{{- $name := include "hugegraph.store.name" . -}}
+{{- $ns := .Release.Namespace -}}
+{{- $port := int .Values.store.ports.rest -}}
+{{- range $i := until $replicas -}}
+ {{- $origins = append $origins (printf "http://%s-%d.%s.%s.svc:%d" $name $i $name $ns $port) -}}
+{{- end -}}
+{{- printf "[%s]" (join "," $origins) -}}
+{{- end }}
+
+{{/*
+Quorum size: floor(replicas/2)+1
+*/}}
+{{- define "hugegraph.pd.quorum" -}}
+{{- add (div (int .Values.pd.replicas) 2) 1 -}}
+{{- end }}
+
+{{/*
+Render JAVA_OPTS only when explicitly configured. An empty value preserves the
+image entrypoint's existing automatic JVM sizing behavior.
+*/}}
+{{- define "hugegraph.javaOptsEnv" -}}
+{{- $javaOpts := default "" . -}}
+{{- if ne (trim $javaOpts) "" -}}
+- name: JAVA_OPTS
+ value: {{ $javaOpts | quote }}
+{{- end -}}
+{{- end }}
+
+{{/*
+String form of a possibly-absent scalar value, preserving zero. sprig's
+`default` treats 0 as unset, which would let a zero slip past the named
+validation below, so absence is detected explicitly instead. Numbers from a
+values file arrive as float64, whose toString switches to scientific
+notation at 1e6 or higher (1000000 becomes "1e+06"), so integral float64
+values are formatted without an exponent. Non-integral floats keep their
+raw form on purpose: the schema already rejects them, and the raw form
+fails the named validation instead of being silently rounded.
+*/}}
+{{- define "hugegraph.optionalScalar" -}}
+{{- if not (kindIs "invalid" .) -}}
+{{- if and (kindIs "float64" .) (eq (floor .) .) -}}{{- printf "%.0f" . -}}{{- else -}}{{- trim (toString .) -}}{{- end -}}
+{{- end -}}
+{{- end }}
+
+{{/*
+Effective PD JAVA_OPTS: chart-derived -D system properties, then pd.javaOpts.
+
+The -D route is the grounded override mechanism: the PD image's
+docker-entrypoint.sh forwards JAVA_OPTS via `-j` into
+bin/start-hugegraph-pd.sh, which places it on the java command line ahead of
+-Dspring.config.location, and Spring system properties outrank the shipped
+conf/application.yml (which pins partition.default-shard-count to 1).
+
+The shard count is always derived, so PD Pods always carry a JAVA_OPTS
+variable, which shadows the PD image's `ENV JAVA_OPTS` default
+(-XX:MaxRAMPercentage=50, -XX:+UseContainerSupport, -XshowSettings:vm).
+That is acceptable: the start script always computes explicit -Xms/-Xmx
+heap flags when the separate JAVA_OPTIONS variable is unset, which makes
+MaxRAMPercentage moot, and only the -XshowSettings:vm startup diagnostics
+are lost.
+
+JVM auto-sizing is preserved, verified against the PD dist start script:
+`-j` lands in USER_OPTION, while the automatic heap sizing branch is gated on
+the separate JAVA_OPTIONS variable and appends USER_OPTION after the computed
+-Xms/-Xmx flags. A JAVA_OPTS holding only -D flags therefore still gets
+automatic heap sizing, and heap flags in pd.javaOpts land later on the
+command line, so they win. The derived -D flags come first for the same
+reason: an explicit duplicate in pd.javaOpts overrides them.
+
+Both -D properties seed PD's persisted config at first bootstrap only:
+ConfigService.loadConfig persists them when no stored config exists, and
+every leader change re-reads the stored values (updatePDConfig), so on an
+initialized cluster the flags are inert and the authoritative values live
+in PD metadata, changeable only through PD's own config API. PD reconciles
+existing shard groups toward the stored value when a partition patrol is
+triggered (TaskScheduleService reallocShards). The empty-value derivation
+is 3 when store.replicas is at least 3, else 1, because PD clamps a shard
+count of 2 to 1 (two shards cannot elect a leader) and its config API
+accepts only odd values. store-max-shard-count is rendered only when set,
+keeping the image default. All lookups tolerate absent keys so releases
+stored before these values existed keep rendering under --reuse-values.
+
+raft.ip-whitelist.enabled is always rendered, default false: PD resolves its
+raft peer allowlist once at boot, which under Kubernetes blocks peers whose
+pod IPs were unpublished at that moment or change later, so the switch is
+off in-cluster per the upstream design and k8s auth owns that layer. Images
+without the property ignore the flag. Set pd.raftIpWhitelistEnabled=true to
+restore the image default.
+
+raft.rpc-timeout is a plain runtime property, applied on every start rather
+than seeded at bootstrap; empty preserves the image default. It bounds raft
+requests between PDs, and on images that wait for a raft ReadIndex before
+they read an id counter, that wait. Images that have raft.rpc-connect-timeout
+use that option for the Ping that opens a connection to a peer (default 1000,
+set through pd.javaOpts); it does not bound requests on a connection that is
+already open. On older images raft.rpc-timeout is the timeout of that Ping as
+well, which is what a leader election waited on.
+*/}}
+{{- define "hugegraph.pd.effectiveJavaOpts" -}}
+{{- $pd := .Values.pd -}}
+{{- $partition := get $pd "partition" | default dict -}}
+{{- $flags := list -}}
+{{- $shardCount := include "hugegraph.optionalScalar" (get $partition "defaultShardCount") -}}
+{{- if eq $shardCount "" -}}
+{{- $shardCount = ternary "3" "1" (ge (int .Values.store.replicas) 3) -}}
+{{- end -}}
+{{- $flags = append $flags (printf "-Dpartition.default-shard-count=%s" $shardCount) -}}
+{{- $maxShard := include "hugegraph.optionalScalar" (get $partition "storeMaxShardCount") -}}
+{{- if ne $maxShard "" -}}
+{{- $flags = append $flags (printf "-Dpartition.store-max-shard-count=%s" $maxShard) -}}
+{{- end -}}
+{{- $ipWhitelist := ternary "true" "false" (eq (get $pd "raftIpWhitelistEnabled" | toString) "true") -}}
+{{- $flags = append $flags (printf "-Draft.ip-whitelist.enabled=%s" $ipWhitelist) -}}
+{{- $rpcTimeout := include "hugegraph.optionalScalar" (get $pd "raftRpcTimeoutMs") -}}
+{{- if ne $rpcTimeout "" -}}
+{{- $flags = append $flags (printf "-Draft.rpc-timeout=%s" $rpcTimeout) -}}
+{{- end -}}
+{{- $userOpts := trim (get $pd "javaOpts" | default "") -}}
+{{- if ne $userOpts "" -}}
+{{- $flags = append $flags $userOpts -}}
+{{- end -}}
+{{- join " " $flags -}}
+{{- end }}
+
+{{/*
+Unspecified probes reserve storageWaitTimeoutSeconds plus 150 seconds.
+An explicitly configured threshold is preserved and checked by validateValues.
+Kubelet may probe immediately: the budget is (failureThreshold - 1) * periodSeconds.
+*/}}
+{{- define "hugegraph.server.startupFailureThreshold" -}}
+{{- .Values.server.probes.startup.failureThreshold -}}
+{{- end }}
+
+{{/*
+Seconds the chart gives the Server image to finish starting, passed as
+HG_SERVER_STARTUP_TIMEOUT_S. The image defaults that to 120 seconds, which is
+shorter than the storage wait alone, so a Server still coming up kills itself
+before Kubernetes has given up on it. The value therefore tracks the startup
+probe's guaranteed alive time, (failureThreshold - 1) * periodSeconds,
+because the first probe can fail immediately, minus the configured storage
+wait the entrypoint runs before the start command, so the start command and
+kubelet give up together instead of the image outliving the probe. Floored
+at the image's own 120-second default, and capped at the entrypoint's 86400
+maximum rather than rendered into a Pod that refuses to start.
+*/}}
+{{- define "hugegraph.server.startupProbeBudget" -}}
+{{- $startup := .Values.server.probes.startup -}}
+{{- add (int ($startup.initialDelaySeconds | default 0)) (mul (sub (int $startup.failureThreshold) 1) (int $startup.periodSeconds)) -}}
+{{- end }}
+
+{{- define "hugegraph.server.startupTimeoutSeconds" -}}
+{{- $budget := include "hugegraph.server.startupProbeBudget" . | int -}}
+{{- min 86400 (max 120 (sub $budget (int .Values.server.storageWaitTimeoutSeconds))) -}}
+{{- end }}
+
+{{/*
+Optional probe tunables, emitted only when explicitly set. Kubernetes defaults
+timeoutSeconds to 1 second, which a garbage-collection pause can exceed on a
+loaded Server; operators need a supported way to raise it without forking the
+chart. Only explicitly configured fields are rendered.
+*/}}
+{{- define "hugegraph.probeTuning" -}}
+{{- if hasKey . "timeoutSeconds" }}
+timeoutSeconds: {{ .timeoutSeconds }}
+{{- end }}
+{{- if hasKey . "initialDelaySeconds" }}
+initialDelaySeconds: {{ .initialDelaySeconds }}
+{{- end }}
+{{- if hasKey . "successThreshold" }}
+successThreshold: {{ .successThreshold }}
+{{- end }}
+{{- end }}
+
+{{/*
+Resolve the ServiceAccount name for a component: an explicit name wins,
+otherwise the generated one when create is true, otherwise "default".
+*/}}
+{{- define "hugegraph.serviceAccountName" -}}
+{{- $sa := get .component "serviceAccount" | default dict -}}
+{{- if get $sa "name" -}}
+{{- get $sa "name" -}}
+{{- else if (get $sa "create" | default false) -}}
+{{- .name -}}
+{{- else -}}
+default
+{{- end -}}
+{{- end }}
+
+{{/*
+The minimum Server replica count that a PDB must remain valid against.
+*/}}
+{{- define "hugegraph.server.replicaFloor" -}}
+{{- if .Values.server.hpa.enabled -}}
+{{- .Values.server.hpa.minReplicas -}}
+{{- else -}}
+{{- .Values.server.replicas -}}
+{{- end -}}
+{{- end }}
+
+{{/*
+Cross-field validation that JSON Schema draft-07 cannot express.
+*/}}
+{{- define "hugegraph.validateValues" -}}
+{{- include "hugegraph.resolveValues" . -}}
+{{- $startupBudget := include "hugegraph.server.startupProbeBudget" . | int -}}
+{{- if lt $startupBudget (add (int .Values.server.storageWaitTimeoutSeconds) 120) -}}
+{{- fail "server.probes.startup.failureThreshold and periodSeconds must leave at least server.storageWaitTimeoutSeconds + 120 seconds to wait for storage and start Server; increase the probe budget or reduce server.storageWaitTimeoutSeconds" -}}
+{{- end -}}
+{{- range $comp := list "pd" "store" "server" "hubble" -}}
+{{- $compLabels := get (get $.Values $comp | default dict) "podLabels" | default dict -}}
+{{- range $reserved := list "app.kubernetes.io/name" "app.kubernetes.io/instance" "app.kubernetes.io/component" -}}
+{{- if hasKey $compLabels $reserved -}}
+{{- fail (printf "%s.podLabels must not set %s: the chart manages it and the workload selectors, Services and PDBs match on it" $comp $reserved) -}}
+{{- end -}}
+{{- end -}}
+{{/* User pod annotations render after the chart's own, and the Kubernetes
+ decoder keeps the last duplicate key, so a fixed checksum/* value would
+ replace the rendered checksum and pin it: rotating a Secret or changing
+ config would no longer roll the pods. */}}
+{{- $compAnnotations := get (get $.Values $comp | default dict) "podAnnotations" | default dict -}}
+{{- range $key, $_ := $compAnnotations -}}
+{{- if hasPrefix "checksum/" $key -}}
+{{- fail (printf "%s.podAnnotations must not set %s: the chart owns the checksum/ annotation prefix, which triggers pod rollouts when resolved Secrets or config change" $comp $key) -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+{{- if and .Values.server.hpa.enabled (gt (int .Values.server.hpa.minReplicas) (int .Values.server.hpa.maxReplicas)) -}}
+{{- fail "server.hpa.minReplicas must be less than or equal to server.hpa.maxReplicas" -}}
+{{- end -}}
+{{- if .Values.server.hpa.enabled -}}
+{{- $serverResources := .Values.server.resources | default dict -}}
+{{- $serverRequests := get $serverResources "requests" | default dict -}}
+{{- if not (hasKey $serverRequests "cpu") -}}
+{{- fail "server.resources.requests.cpu is required when server.hpa.enabled=true" -}}
+{{- end -}}
+{{- $cpuRequest := trim (toString (get $serverRequests "cpu")) -}}
+{{- if or (eq $cpuRequest "") (hasPrefix "-" $cpuRequest) (regexMatch "^[+]?((0+([.]0*)?)|([.]0+))(([KMGTPE]i)|[numkMGTPE]|[eE][+-]?[0-9]+)?$" $cpuRequest) -}}
+{{- fail "server.resources.requests.cpu must be strictly positive when server.hpa.enabled=true" -}}
+{{- end -}}
+{{- end -}}
+{{/*
+Raft and shard membership are persisted; deleting Pods does not reconfigure
+them, so an in-place replica shrink permanently loses PD quorum or Store
+shard majorities. The guard reads the live StatefulSet, so it fires only on
+a real upgrade against a cluster; template-only renders have no live object
+and skip it. An operator who has completed the documented manual scale-down
+procedure has already scaled the live StatefulSet, so desired equals live
+and the upgrade passes.
+*/}}
+{{- if .Release.IsUpgrade -}}
+{{- range $comp := list "pd" "store" -}}
+{{- $stsName := "" -}}
+{{- if eq $comp "pd" -}}{{- $stsName = include "hugegraph.pd.name" $ -}}{{- else -}}{{- $stsName = include "hugegraph.store.name" $ -}}{{- end -}}
+{{- $live := lookup "apps/v1" "StatefulSet" $.Release.Namespace $stsName -}}
+{{- if $live -}}
+{{- $wasPersistent := false -}}
+{{- range $claim := get (get $live "spec") "volumeClaimTemplates" | default list -}}
+{{- if eq (dig "metadata" "name" "" $claim) (printf "%s-data" $comp) -}}{{- $wasPersistent = true -}}{{- end -}}
+{{- end -}}
+{{- if ne $wasPersistent (get (get (get $.Values $comp) "storage") "enabled") -}}
+{{- fail (printf "%s.storage.enabled cannot change on an initialized release: StatefulSet volumeClaimTemplates are immutable and changing storage can discard data. Use a fresh release and migrate data" $comp) -}}
+{{- end -}}
+
+{{- $liveReplicas := int (dig "spec" "replicas" 0 $live) -}}
+{{- $desired := int (get (get $.Values $comp) "replicas") -}}
+{{- if and (gt $liveReplicas 0) (lt $desired $liveReplicas) -}}
+{{- $procedure := ternary "Reconfigure the persisted PD voting membership before scaling the live StatefulSet" "Retire leaving Store IDs and migrate their shards before scaling the live StatefulSet" (eq $comp "pd") -}}
+{{- fail (printf "%s.replicas cannot shrink from %d to %d through a helm upgrade: persisted membership is not changed by removing Pods. %s; see docs/operations.md (Topology and scheduling). The upgrade passes once live replicas match the value" $comp $liveReplicas $desired $procedure) -}}
+{{- end -}}
+{{/*
+PD raft membership is the persisted voting configuration, and the peer list
+the chart renders reaches it only as NodeOptions.setInitialConf, which jraft
+applies when bootstrapping a node that has no configuration of its own. On an
+initialized group, adding Pods adds non-voting strangers: the extra PD starts,
+the peer list changes, and the voting configuration does not. PD exposes the
+change through RaftEngine.changePeerList, reachable from the PD client API but
+from no REST route, so the chart cannot perform it and does not pretend to.
+Growing Store is ordinary scale-out and stays allowed.
+*/}}
+{{- if and (eq $comp "pd") (gt $liveReplicas 0) (gt $desired $liveReplicas) -}}
+{{- fail (printf "pd.replicas cannot grow from %d to %d through a helm upgrade: the rendered peer list reaches raft only as the initial configuration, so new Pods would start without joining the voting configuration. Change the persisted membership through PD first, then scale the live StatefulSet, then upgrade with the matching value; see docs/operations.md (Topology and scheduling) for the supported lifecycle. A fresh install at any replica count is unaffected" $liveReplicas $desired) -}}
+{{- end -}}
+{{/*
+Raft identity. Every PD and Store names itself to its peers as
+...svc:, and that address is what the
+persisted membership records (PD's voting configuration, each Store shard
+group's peer list). The port reaches raft only through the bootstrap
+configuration, and the StatefulSet name is the DNS name, so a Pod restarted
+on a changed port is a stranger to the group it belongs to: it cannot rejoin,
+and once enough Pods have been replaced the group has no quorum. The live
+StatefulSet carries the port it was initialized with, so a change is refused
+there; a fresh install chooses freely.
+*/}}
+{{- $desiredRaft := int (get (get (get $.Values $comp) "ports" | default dict) "raft") -}}
+{{- range $container := dig "spec" "template" "spec" "containers" list $live -}}
+{{- if eq (get $container "name" | default "") $comp -}}
+{{- range $port := get $container "ports" | default list -}}
+{{- if and (eq (get $port "name" | default "") "raft") (ne (int (get $port "containerPort")) $desiredRaft) -}}
+{{- fail (printf "%s.ports.raft cannot change from %d to %d on an initialized release: the raft address .%s..svc: is the identity the persisted raft membership records, and a Pod restarted on the new port cannot rejoin its group%s. Keep the value; changing the port needs a fresh install, or a migration of the persisted membership before the upgrade" $comp (int (get $port "containerPort")) $desiredRaft $stsName (ternary " (a single PD loses service outright)" "" (eq $comp "pd"))) -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+{{/*
+nameOverride also feeds the app.kubernetes.io/name selector label. A
+StatefulSet selector is immutable, so an override that keeps the name (one
+contained in the release name) but changes that label is refused by the API
+server mid-upgrade; refuse it here with the reason instead.
+*/}}
+{{- $liveName := dig "spec" "selector" "matchLabels" "app.kubernetes.io/name" "" $live -}}
+{{- if and $liveName (ne $liveName (include "hugegraph.name" $)) -}}
+{{- fail (printf "nameOverride changes the app.kubernetes.io/name selector label of the %s StatefulSet %s from %s to %s, which Kubernetes forbids on an existing StatefulSet. Restore the previous override; a rename needs a fresh install and a data migration" $comp $stsName $liveName (include "hugegraph.name" $)) -}}
+{{- end -}}
+{{- end -}}
+{{/*
+The same identity is carried by the StatefulSet name, which nameOverride and
+fullnameOverride change. The renamed StatefulSet does not exist yet, so it
+cannot be looked up by name; the release's existing StatefulSets are found by
+the instance and component labels instead, which do not depend on the name.
+*/}}
+{{- $released := lookup "apps/v1" "StatefulSet" $.Release.Namespace "" -}}
+{{- range $sts := get $released "items" | default list -}}
+{{- $labels := dig "metadata" "labels" dict $sts -}}
+{{- if and (eq (get $labels "app.kubernetes.io/instance" | default "") $.Release.Name) (eq (get $labels "app.kubernetes.io/component" | default "") $comp) (ne (dig "metadata" "name" "" $sts) $stsName) -}}
+{{- fail (printf "release %s already owns the %s StatefulSet %s, but the values now name it %s (nameOverride or fullnameOverride changed): the StatefulSet name is the raft address of every Pod and the prefix of every PersistentVolumeClaim, so the rename would start empty Pods under new identities and leave the data volumes detached. Restore the previous override; a rename needs a fresh install and a data migration" $.Release.Name $comp (dig "metadata" "name" "" $sts) $stsName) -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+{{- $hubbleLive := lookup "apps/v1" "Deployment" .Release.Namespace (include "hugegraph.hubble.name" .) -}}
+{{- if and $hubbleLive .Values.hubble.enabled -}}
+{{- $wasPersistent := false -}}
+{{- $hasDataVolume := false -}}
+{{- range $volume := dig "spec" "template" "spec" "volumes" list $hubbleLive -}}
+{{- if eq ($volume.name | default "") "data" -}}{{- $hasDataVolume = true -}}{{- end -}}
+{{- if and (eq ($volume.name | default "") "data") (hasKey $volume "persistentVolumeClaim") -}}{{- $wasPersistent = true -}}{{- end -}}
+{{- end -}}
+{{- if ne $wasPersistent .Values.hubble.persistence.enabled -}}
+{{- if not $hasDataVolume -}}
+{{- fail "hubble.persistence.enabled cannot change on an initialized release: legacy Hubble has no data volume and its UI records are in the container filesystem. Keep hubble.persistence.enabled=false with --reuse-values or an explicit GitOps value; enabling persistence requires a reviewed metadata migration first" -}}
+{{- end -}}
+{{- fail "hubble.persistence.enabled cannot change on an initialized release: changing storage can discard UI records. Migrate the data into a fresh deployment first" -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+{{/*
+Only validate minAvailable where a PDB is actually rendered. The pd/store PDB
+templates require replicas > 1, so a single-replica release never creates one
+and must not be failed for a value that has no effect.
+*/}}
+{{- if and .Values.pd.pdb.enabled (gt (int .Values.pd.replicas) 1) (ge (int .Values.pd.pdb.minAvailable) (int .Values.pd.replicas)) -}}
+{{- fail "pd.pdb.minAvailable must be less than pd.replicas, otherwise the PDB permanently blocks voluntary disruptions such as node drains" -}}
+{{- end -}}
+{{- if and .Values.pd.pdb.enabled (gt (int .Values.pd.replicas) 1) (lt (int .Values.pd.pdb.minAvailable) (include "hugegraph.pd.quorum" . | int)) -}}
+{{- fail "pd.pdb.minAvailable must be at least the PD Raft majority, floor(replicas/2)+1, otherwise the budget permits voluntary disruptions that drop PD below quorum. Note a PDB only limits voluntary disruption such as drains and evictions; it cannot protect quorum from node failure" -}}
+{{- end -}}
+{{- if and .Values.store.pdb.enabled (gt (int .Values.store.replicas) 1) (ge (int .Values.store.pdb.minAvailable) (int .Values.store.replicas)) -}}
+{{- fail "store.pdb.minAvailable must be less than store.replicas, otherwise the PDB permanently blocks voluntary disruptions such as node drains" -}}
+{{- end -}}
+{{- if and .Values.store.pdb.enabled (gt (int .Values.store.replicas) 1) (lt (int .Values.store.pdb.minAvailable) (sub (int .Values.store.replicas) 1)) -}}
+{{- fail "store.pdb.minAvailable must be at least store.replicas - 1: each shard keeps its copies on a subset of the Stores, so permitting more than one concurrent voluntary eviction can remove a shard majority regardless of the Store count" -}}
+{{- end -}}
+{{/*
+PD -D system properties must be empty or a positive integer. The schema
+enforces the types; these checks add a named failure for zero, negative, and
+nonsense values, and check an explicit shard count against PD's real
+constraints: PD's config API accepts only odd shard counts, PD clamps a
+count of 2 to 1 (two shards cannot elect a leader), and PD clamps the
+effective count to the number of live stores. All lookups tolerate absent
+keys for releases stored before the values existed.
+*/}}
+{{- $pdPartition := get .Values.pd "partition" | default dict -}}
+{{- $pdProps := dict
+ "pd.partition.defaultShardCount" (include "hugegraph.optionalScalar" (get $pdPartition "defaultShardCount"))
+ "pd.partition.storeMaxShardCount" (include "hugegraph.optionalScalar" (get $pdPartition "storeMaxShardCount")) -}}
+{{- range $label, $raw := $pdProps -}}
+{{- if and (ne $raw "") (or (not (regexMatch "^[0-9]+$" $raw)) (eq (int $raw) 0)) -}}
+{{- fail (printf "%s must be empty or a positive integer" $label) -}}
+{{- end -}}
+{{- end -}}
+{{- $explicitShards := include "hugegraph.optionalScalar" (get $pdPartition "defaultShardCount") -}}
+{{- if regexMatch "^[1-9][0-9]*$" $explicitShards -}}
+{{- if eq (mod (int $explicitShards) 2) 0 -}}
+{{- fail "pd.partition.defaultShardCount must be odd: PD's config API rejects even shard counts, and PD clamps a bootstrap value of 2 to 1 because two shards cannot elect a leader" -}}
+{{- end -}}
+{{- if gt (int $explicitShards) (int .Values.store.replicas) -}}
+{{- fail "pd.partition.defaultShardCount is greater than store.replicas: PD would clamp the effective shard count to the number of live stores, so the extra replicas would silently never be placed. Raise store.replicas or lower the shard count" -}}
+{{- end -}}
+{{- end -}}
+{{- $svc := get .Values.server "service" | default dict -}}
+{{- if and (get $svc "nodePort") (not (has (get $svc "type" | default "ClusterIP") (list "NodePort" "LoadBalancer"))) -}}
+{{- fail "server.service.nodePort requires server.service.type to be NodePort or LoadBalancer" -}}
+{{- end -}}
+{{- $advertiseUrl := trim (default "" .Values.server.advertiseUrl) -}}
+{{- if and $advertiseUrl (not (or (hasPrefix "http://" $advertiseUrl) (hasPrefix "https://" $advertiseUrl))) -}}
+{{- fail "server.advertiseUrl must be an absolute http:// or https:// URL when set" -}}
+{{- end -}}
+{{- $pdSvc := get .Values.pd "service" | default dict -}}
+{{- $pdSvcType := get $pdSvc "type" | default "ClusterIP" -}}
+{{- if and (or (get $pdSvc "restNodePort") (get $pdSvc "grpcNodePort")) (not (has $pdSvcType (list "NodePort" "LoadBalancer"))) -}}
+{{- fail "pd.service.restNodePort and pd.service.grpcNodePort require pd.service.type to be NodePort or LoadBalancer" -}}
+{{- end -}}
+{{- $serverPdb := get .Values.server "pdb" | default dict -}}
+{{- $serverReplicaFloor := include "hugegraph.server.replicaFloor" . | int -}}
+{{- if and (get $serverPdb "enabled" | default false) (gt $serverReplicaFloor 1) (ge (int (get $serverPdb "minAvailable" | default 1)) $serverReplicaFloor) -}}
+{{- fail "server.pdb.minAvailable must be less than the active Server replica floor (server.hpa.minReplicas when HPA is enabled, otherwise server.replicas), otherwise the PDB permanently blocks voluntary disruptions such as node drains" -}}
+{{- end -}}
+{{- $serverIngress := get .Values.server "ingress" | default dict -}}
+{{- if get (get .Values.server "securityContext" | default dict) "readOnlyRootFilesystem" | default false -}}
+{{- fail "server.securityContext.readOnlyRootFilesystem=true breaks Server: its wrapper rewrites conf/rest-server.properties inside the image at startup and the chart mounts no writable volume there" -}}
+{{- end -}}
+{{/*
+extraEnv entries render after the chart-owned variables and Kubernetes lets
+the last duplicate win, so a duplicate name would silently override a
+validated contract (for example re-enabling init-store across Server
+replicas). Reserved names are rejected instead. JAVA_OPTIONS is reserved
+for pd, store, and server because each component's start script skips its
+automatic heap sizing and drops the chart's JAVA_OPTS flags entirely when
+JAVA_OPTIONS arrives preset in the environment (verified in
+start-hugegraph-pd.sh, start-hugegraph-store.sh, and hugegraph-server.sh).
+*/}}
+{{- $reservedEnv := dict
+ "pd" (list "HG_PD_GRPC_HOST" "HG_PD_GRPC_PORT" "HG_PD_REST_PORT" "HG_PD_RAFT_ADDRESS" "HG_PD_RAFT_PEERS_LIST" "HG_PD_INITIAL_STORE_LIST" "HG_PD_INITIAL_STORE_COUNT" "HG_PD_DATA_PATH" "HG_PD_AUTH_SECRET_KEY" "JAVA_OPTS" "JAVA_OPTIONS")
+ "store" (list "HG_STORE_PD_ADDRESS" "HG_STORE_GRPC_HOST" "HG_STORE_GRPC_PORT" "HG_STORE_REST_PORT" "HG_STORE_RAFT_ADDRESS" "HG_STORE_DATA_PATH" "JAVA_OPTS" "JAVA_OPTIONS")
+ "server" (list "POD_IP" "HG_SERVER_SERVICE_ID" "HG_SERVER_GRAPHSPACE" "SERVICE_ID" "GRAPH_SPACE" "HG_SERVER_BACKEND" "HG_SERVER_PD_PEERS" "HG_SERVER_PD_REST_ENDPOINT" "STORE_REST" "HG_SERVER_INIT_STORE_ENABLED" "HG_SERVER_URLS_TO_PD" "HG_SERVER_DEPLOY_IN_K8S" "HG_SERVER_STARTUP_TIMEOUT_S" "WAIT_STORAGE_TIMEOUT_S" "PD_AUTH_PASSWORD" "PASSWORD" "HG_SERVER_AUTH_TOKEN_SECRET" "JAVA_OPTS" "JAVA_OPTIONS")
+ "hubble" (list "HG_HUBBLE_PD_PEERS" "HG_HUBBLE_PD_SERVER" "HG_HUBBLE_PD_PASSWORD" "HG_HUBBLE_STORE_TARGETS" "HG_HUBBLE_SERVER_URL" "SPRING_DATASOURCE_URL") -}}
+{{- range $component, $reserved := $reservedEnv -}}
+{{- $componentValues := get $.Values $component | default dict -}}
+{{- range $entry := get $componentValues "extraEnv" | default list -}}
+{{- if has (get $entry "name") $reserved -}}
+{{- fail (printf "%s.extraEnv must not set the chart-managed variable %s" $component (get $entry "name")) -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+{{- $hubble := get .Values "hubble" | default dict -}}
+{{- if get $hubble "enabled" | default false -}}
+{{- if and (not .Values.server.auth.enabled) (not (get $hubble "allowWithoutServerAuth" | default false)) -}}
+{{- fail "hubble.enabled requires server.auth: current Hubble images authenticate against the cluster and cannot complete their login on an auth-less deployment. Enable server.auth, or set hubble.allowWithoutServerAuth=true for images that support it" -}}
+{{- end -}}
+{{- $hubbleSvc := get $hubble "service" | default dict -}}
+{{- if and (get $hubbleSvc "nodePort") (not (has (get $hubbleSvc "type" | default "ClusterIP") (list "NodePort" "LoadBalancer"))) -}}
+{{- fail "hubble.service.nodePort requires hubble.service.type to be NodePort or LoadBalancer" -}}
+{{- end -}}
+{{- $hubbleImage := get $hubble "image" | default dict -}}
+{{- if and (eq (trim (get $hubbleImage "tag" | default "")) "") (eq (trim (get $hubbleImage "digest" | default "")) "") -}}
+{{- fail "hubble.image needs a tag or a digest: the chart appVersion tracks the Server release, not Hubble, so there is no meaningful fallback" -}}
+{{- end -}}
+{{- if get (get $hubble "securityContext" | default dict) "readOnlyRootFilesystem" | default false -}}
+{{- fail "hubble.securityContext.readOnlyRootFilesystem=true breaks Hubble: its wrapper writes conf/hugegraph-hubble.properties inside the image at startup and the chart mounts no writable volume there" -}}
+{{- end -}}
+{{- $hubbleIngress := get $hubble "ingress" | default dict -}}
+{{- end -}}
+{{- $pdAuth := get .Values.pd "auth" | default dict -}}
+{{/* A values set with no pd.auth block at all (a release stored before the
+ field existed, replayed by --reuse-values) gets the chart default,
+ autoGenerate; only an explicit autoGenerate=false with nothing else set
+ is an error. */}}
+{{- $pdAutoGen := ternary (get $pdAuth "autoGenerate") true (hasKey $pdAuth "autoGenerate") -}}
+{{- if and (not (get $pdAuth "existingSecret" | default "")) (not (get $pdAuth "value" | default "")) (not $pdAutoGen) -}}
+{{- fail "pd.auth requires existingSecret, value, or autoGenerate=true: PD refuses to start without a REST secret" -}}
+{{- end -}}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $admin := get $auth "admin" | default dict -}}
+{{- $token := get $auth "token" | default dict -}}
+{{- if get $auth "enabled" | default false -}}
+{{- if and (not (get $admin "existingSecret" | default "")) (not (get $admin "password" | default "")) (not (get $admin "autoGenerate" | default false)) -}}
+{{- fail "server.auth.admin requires existingSecret, password, or autoGenerate=true when auth is enabled" -}}
+{{- end -}}
+{{- if and (not (get $token "existingSecret" | default "")) (not (get $token "value" | default "")) (not (get $token "autoGenerate" | default false)) -}}
+{{- fail "server.auth.token requires existingSecret, value, or autoGenerate=true when auth is enabled" -}}
+{{- end -}}
+{{- end -}}
+
+{{- end }}
+
+{{/*
+podAntiAffinity snippet for a component label key.
+mode: required | preferred | disabled
+*/}}
+{{/*
+NetworkPolicy building blocks: a same-release peer by component, a TCP port
+list, and DNS egress by port only, so it works wherever the cluster runs its
+resolver (CoreDNS in any namespace, NodeLocal DNSCache).
+*/}}
+{{- define "hugegraph.netpol.peer" -}}
+- podSelector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" .root | nindent 6 }}
+ app.kubernetes.io/component: {{ .component }}
+{{- end }}
+
+{{- define "hugegraph.netpol.ports" -}}
+{{- $rules := list -}}
+{{- range . -}}
+{{- $rules = append $rules (printf "- protocol: TCP\n port: %d" (int .)) -}}
+{{- end -}}
+{{- join "\n" $rules -}}
+{{- end }}
+
+{{- define "hugegraph.netpol.dns" -}}
+- ports:
+ - protocol: UDP
+ port: 53
+ - protocol: TCP
+ port: 53
+{{- end }}
+
+{{- define "hugegraph.antiAffinity" -}}
+{{- $mode := .mode -}}
+{{- $component := .component -}}
+{{- $labels := .labels -}}
+{{- if eq $mode "required" }}
+affinity:
+ podAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - labelSelector:
+ matchLabels:
+ {{- toYaml $labels | nindent 12 }}
+ app.kubernetes.io/component: {{ $component }}
+ topologyKey: kubernetes.io/hostname
+{{- else if eq $mode "preferred" }}
+affinity:
+ podAntiAffinity:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ podAffinityTerm:
+ labelSelector:
+ matchLabels:
+ {{- toYaml $labels | nindent 14 }}
+ app.kubernetes.io/component: {{ $component }}
+ topologyKey: kubernetes.io/hostname
+{{- end }}
+{{- end }}
+
+{{/*
+Render a container image reference. An explicit image.digest pins immutably and wins
+over tag; otherwise fall back to tag, then to the chart appVersion. Takes a dict of
+(image, appVersion).
+*/}}
+{{- define "hugegraph.image" -}}
+{{- $img := .image -}}
+{{- $digest := trim (get $img "digest" | default "") -}}
+{{- if ne $digest "" -}}
+{{- printf "%s@%s" $img.repository $digest -}}
+{{- else -}}
+{{- printf "%s:%s" $img.repository (default .appVersion $img.tag) -}}
+{{- end -}}
+{{- end }}
diff --git a/helm/hugegraph/templates/hubble-deployment.yaml b/helm/hugegraph/templates/hubble-deployment.yaml
new file mode 100644
index 0000000000..bd83736de2
--- /dev/null
+++ b/helm/hugegraph/templates/hubble-deployment.yaml
@@ -0,0 +1,318 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- if get (get .Values "hubble" | default dict) "enabled" | default false }}
+{{- include "hugegraph.validateValues" . }}
+{{- $customPort := ne (int .Values.hubble.port) 8088 }}
+{{- $mode := .Values.hubble.mode | default "pd" }}
+{{- if not (has $mode (list "pd" "direct")) }}
+{{- fail "hubble.mode must be one of: pd, direct" }}
+{{- end }}
+{{- $pdMode := eq $mode "pd" }}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "hugegraph.hubble.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: hubble
+spec:
+ # Hubble keeps its connection metadata in an embedded per-instance H2
+ # database, so this Deployment is fixed at one replica and replaces the Pod
+ # instead of rolling, which also keeps a persistent volume single-attached.
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: hubble
+ template:
+ metadata:
+ labels:
+ {{- include "hugegraph.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: hubble
+ {{- with .Values.hubble.podLabels }}{{ toYaml . | nindent 8 }}{{- end }}
+ annotations:
+ {{- if $pdMode }}
+ # Rolls Hubble when the PD REST Secret written into its properties changes.
+ checksum/pd-auth: {{ include "hugegraph.pd.authChecksum" . | quote }}
+ {{- end }}
+ {{- with .Values.hubble.podAnnotations }}{{ toYaml . | nindent 8 }}{{- end }}
+ spec:
+ automountServiceAccountToken: {{ get (get .Values.hubble "serviceAccount" | default dict) "automountServiceAccountToken" | default false }}
+ serviceAccountName: {{ include "hugegraph.serviceAccountName" (dict "component" .Values.hubble "name" (include "hugegraph.hubble.name" .) ) }}
+ {{- if hasKey .Values.hubble "terminationGracePeriodSeconds" }}
+ terminationGracePeriodSeconds: {{ .Values.hubble.terminationGracePeriodSeconds }}
+ {{- end }}
+ {{- with .Values.hubble.priorityClassName }}
+ priorityClassName: {{ . | quote }}
+ {{- end }}
+ {{- with .Values.hubble.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.hubble.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.hubble.topologySpreadConstraints }}
+ topologySpreadConstraints:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.hubble.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.hubble.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ - name: hubble
+ image: {{ include "hugegraph.image" (dict "image" .Values.hubble.image "appVersion" $.Chart.AppVersion) | quote }}
+ imagePullPolicy: {{ .Values.hubble.image.pullPolicy }}
+ {{- with .Values.hubble.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ # The Hubble image reads conf/hugegraph-hubble.properties only; it
+ # has no environment mapping for these keys, so the wrapper rewrites
+ # them before handing off to the image's own start script. The image
+ # ships no dumb-init; start-hubble.sh -f execs java, which then
+ # receives signals directly.
+ command:
+ - /bin/bash
+ - -c
+ args:
+ - |
+ set -euo pipefail
+ CONF=./conf/hugegraph-hubble.properties
+ if [[ ! -r "${CONF}" ]]; then
+ echo "missing or unreadable ${CONF}; refusing to write a stub config" >&2
+ exit 1
+ fi
+ {{- if $pdMode }}
+ : "${HG_HUBBLE_PD_PASSWORD:?PD REST secret must not be empty}"
+ # The value is written into a Java properties file, whose
+ # parser unescapes backslashes and trims whitespace, so these
+ # shapes would silently store a credential that differs from
+ # the Secret. Inline pd.auth values are schema-checked; an
+ # external existingSecret is only seen here.
+ case "${HG_HUBBLE_PD_PASSWORD}" in
+ *\\* | ' '* | *' ')
+ echo "the PD REST secret must not contain backslashes or" \
+ "leading or trailing whitespace; fix the pd.auth" \
+ "Secret value" >&2
+ exit 1
+ ;;
+ esac
+ # Byte check under the C locale, independent of the image
+ # locale: in a UTF-8 locale a POSIX print class accepts
+ # multibyte characters, which the ISO-8859-1 properties reader
+ # then decodes into a credential PD never received. Anything
+ # outside 0x20-0x7E (tabs, newlines, non-ASCII) is refused.
+ if (( $(printf '%s' "${HG_HUBBLE_PD_PASSWORD}" | LC_ALL=C tr -d '\040-\176' | wc -c) > 0 )); then
+ echo "the PD REST secret must be printable ASCII (bytes 0x20-0x7E:" \
+ "no tabs, newlines or non-ASCII characters); fix the" \
+ "pd.auth Secret value" >&2
+ exit 1
+ fi
+ {{- end }}
+ TMP=$(mktemp)
+ FOUND_PD_ENABLED=false
+ {{- if $pdMode }}
+ FOUND_PD_PEERS=false
+ FOUND_PD_SERVER=false
+ FOUND_PD_USERNAME=false
+ FOUND_PD_PASSWORD=false
+ FOUND_STORE_TARGETS=false
+ {{- else }}
+ FOUND_DIRECT_URL=false
+ {{- end }}
+ FOUND_SERVER_HOST=false
+ {{- if $customPort }}
+ FOUND_HUBBLE_PORT=false
+ FOUND_SERVER_PORT=false
+ {{- end }}
+ while IFS= read -r LINE || [[ -n "${LINE}" ]]; do
+ case "${LINE}" in
+ pd.enabled=*)
+ printf 'pd.enabled={{ $pdMode }}\n' >>"${TMP}"
+ FOUND_PD_ENABLED=true
+ ;;
+ {{- if $pdMode }}
+ pd.peers=*)
+ printf 'pd.peers=%s\n' "${HG_HUBBLE_PD_PEERS}" >>"${TMP}"
+ FOUND_PD_PEERS=true
+ ;;
+ pd.server=*)
+ printf 'pd.server=%s\n' "${HG_HUBBLE_PD_SERVER}" >>"${TMP}"
+ FOUND_PD_SERVER=true
+ ;;
+ operations.pd.username=*)
+ printf 'operations.pd.username=hubble\n' >>"${TMP}"
+ FOUND_PD_USERNAME=true
+ ;;
+ operations.pd.password=*)
+ printf 'operations.pd.password=%s\n' "${HG_HUBBLE_PD_PASSWORD}" >>"${TMP}"
+ FOUND_PD_PASSWORD=true
+ ;;
+ operations.store.allowed_targets=*)
+ printf 'operations.store.allowed_targets=%s\n' \
+ "${HG_HUBBLE_STORE_TARGETS}" >>"${TMP}"
+ FOUND_STORE_TARGETS=true
+ ;;
+ {{- else }}
+ server.direct_url=*)
+ printf 'server.direct_url=%s\n' "${HG_HUBBLE_SERVER_URL}" >>"${TMP}"
+ FOUND_DIRECT_URL=true
+ ;;
+ {{- end }}
+ server.host=*)
+ printf 'server.host=0.0.0.0\n' >>"${TMP}"
+ FOUND_SERVER_HOST=true
+ ;;
+ {{- if $customPort }}
+ hubble.port=*)
+ printf 'hubble.port=%s\n' {{ .Values.hubble.port | quote }} >>"${TMP}"
+ FOUND_HUBBLE_PORT=true
+ ;;
+ server.port=*)
+ printf 'server.port=%s\n' {{ .Values.hubble.port | quote }} >>"${TMP}"
+ FOUND_SERVER_PORT=true
+ ;;
+ {{- end }}
+ *)
+ printf '%s\n' "${LINE}" >>"${TMP}"
+ ;;
+ esac
+ done <"${CONF}"
+ if [[ "${FOUND_PD_ENABLED}" == false ]]; then
+ printf 'pd.enabled={{ $pdMode }}\n' >>"${TMP}"
+ fi
+ {{- if $pdMode }}
+ if [[ "${FOUND_PD_PEERS}" == false ]]; then
+ printf 'pd.peers=%s\n' "${HG_HUBBLE_PD_PEERS}" >>"${TMP}"
+ fi
+ if [[ "${FOUND_PD_SERVER}" == false ]]; then
+ printf 'pd.server=%s\n' "${HG_HUBBLE_PD_SERVER}" >>"${TMP}"
+ fi
+ # PD REST credential for the operations API.
+ if [[ "${FOUND_PD_USERNAME}" == false ]]; then
+ printf 'operations.pd.username=hubble\n' >>"${TMP}"
+ fi
+ if [[ "${FOUND_PD_PASSWORD}" == false ]]; then
+ printf 'operations.pd.password=%s\n' "${HG_HUBBLE_PD_PASSWORD}" >>"${TMP}"
+ fi
+ if [[ "${FOUND_STORE_TARGETS}" == false ]]; then
+ printf 'operations.store.allowed_targets=%s\n' \
+ "${HG_HUBBLE_STORE_TARGETS}" >>"${TMP}"
+ fi
+ {{- else }}
+ if [[ "${FOUND_DIRECT_URL}" == false ]]; then
+ printf 'server.direct_url=%s\n' "${HG_HUBBLE_SERVER_URL}" >>"${TMP}"
+ fi
+ {{- end }}
+ # Current Hubble binds server.host, which defaults to
+ # localhost; the shipped conf's hubble.host=0.0.0.0 line is
+ # legacy, is ignored by current images, and is preserved
+ # verbatim above for images that still read it.
+ if [[ "${FOUND_SERVER_HOST}" == false ]]; then
+ printf 'server.host=0.0.0.0\n' >>"${TMP}"
+ fi
+ {{- if $customPort }}
+ if [[ "${FOUND_HUBBLE_PORT}" == false ]]; then
+ printf 'hubble.port=%s\n' {{ .Values.hubble.port | quote }} >>"${TMP}"
+ fi
+ if [[ "${FOUND_SERVER_PORT}" == false ]]; then
+ printf 'server.port=%s\n' {{ .Values.hubble.port | quote }} >>"${TMP}"
+ fi
+ {{- end }}
+ chmod 600 "${TMP}"
+ mv "${TMP}" "${CONF}"
+ exec ./bin/start-hubble.sh -f
+ ports:
+ - name: http
+ containerPort: {{ .Values.hubble.port }}
+ env:
+ {{- if $pdMode }}
+ - name: HG_HUBBLE_PD_PEERS
+ value: {{ include "hugegraph.pd.grpcPeersList" . | quote }}
+ - name: HG_HUBBLE_PD_SERVER
+ value: {{ include "hugegraph.pd.restClientEndpoint" . | quote }}
+ - name: HG_HUBBLE_PD_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "hugegraph.pd.authSecretName" . | quote }}
+ key: {{ include "hugegraph.pd.authSecretKey" . | quote }}
+ - name: HG_HUBBLE_STORE_TARGETS
+ value: {{ include "hugegraph.store.restOriginsList" . | quote }}
+ {{- else }}
+ - name: HG_HUBBLE_SERVER_URL
+ value: {{ include "hugegraph.server.clientUrl" . | quote }}
+ {{- end }}
+ # Hubble's H2 location is fixed to ./db inside the image classpath
+ # config; Spring Boot's environment binding is the supported way to
+ # point it into the mounted volume.
+ - name: SPRING_DATASOURCE_URL
+ value: "jdbc:h2:file:/hubble-data/db;DB_CLOSE_ON_EXIT=FALSE"
+ {{- with .Values.hubble.extraEnv }}{{ toYaml . | nindent 12 }}{{- end }}
+ volumeMounts:
+ - name: data
+ mountPath: /hubble-data
+ startupProbe:
+ httpGet:
+ path: /actuator/health
+ port: http
+ failureThreshold: {{ .Values.hubble.probes.startup.failureThreshold }}
+ periodSeconds: {{ .Values.hubble.probes.startup.periodSeconds }}
+ {{- with include "hugegraph.probeTuning" .Values.hubble.probes.startup }}{{ . | trim | nindent 12 }}{{- end }}
+ readinessProbe:
+ httpGet:
+ path: /actuator/health
+ port: http
+ periodSeconds: {{ .Values.hubble.probes.readiness.periodSeconds }}
+ failureThreshold: {{ .Values.hubble.probes.readiness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.hubble.probes.readiness }}{{ . | trim | nindent 12 }}{{- end }}
+ # TCP for liveness: /actuator/health aggregates datasource health,
+ # and a transiently slow persistent volume must not get a healthy
+ # JVM killed (Recreate + RWO makes an overlapping restart worse).
+ livenessProbe:
+ tcpSocket:
+ port: http
+ periodSeconds: {{ .Values.hubble.probes.liveness.periodSeconds }}
+ failureThreshold: {{ .Values.hubble.probes.liveness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.hubble.probes.liveness }}{{ . | trim | nindent 12 }}{{- end }}
+ {{- with .Values.hubble.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ volumes:
+ - name: data
+ {{- if .Values.hubble.persistence.enabled }}
+ persistentVolumeClaim:
+ claimName: {{ include "hugegraph.hubble.dataName" . }}
+ {{- else }}
+ emptyDir: {}
+ {{- end }}
+{{- end }}
diff --git a/helm/hugegraph/templates/hubble-ingress.yaml b/helm/hugegraph/templates/hubble-ingress.yaml
new file mode 100644
index 0000000000..9b69bbdf94
--- /dev/null
+++ b/helm/hugegraph/templates/hubble-ingress.yaml
@@ -0,0 +1,57 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $hubble := get .Values "hubble" | default dict }}
+{{- if and (get $hubble "enabled" | default false) .Values.hubble.ingress.enabled }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "hugegraph.hubble.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: hubble
+ {{- with (get .Values.hubble.ingress "annotations") }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if .Values.hubble.ingress.className }}
+ ingressClassName: {{ .Values.hubble.ingress.className | quote }}
+ {{- end }}
+ {{- with .Values.hubble.ingress.tls }}
+ tls:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ rules:
+ {{- range (.Values.hubble.ingress.hosts | default (list (dict "paths" (list (dict "path" "/" "pathType" "Prefix"))))) }}
+ - {{- with .host }}
+ host: {{ . | quote }}
+ {{- end }}
+ http:
+ paths:
+ {{- range (.paths | default (list (dict "path" "/" "pathType" "Prefix"))) }}
+ - path: {{ .path | quote }}
+ pathType: {{ .pathType }}
+ backend:
+ service:
+ name: {{ include "hugegraph.hubble.name" $ }}
+ port:
+ number: {{ $.Values.hubble.port }}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/helm/hugegraph/templates/hubble-pvc.yaml b/helm/hugegraph/templates/hubble-pvc.yaml
new file mode 100644
index 0000000000..a1396a6918
--- /dev/null
+++ b/helm/hugegraph/templates/hubble-pvc.yaml
@@ -0,0 +1,44 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $hubble := get .Values "hubble" | default dict }}
+{{- if and (get $hubble "enabled" | default false) .Values.hubble.persistence.enabled }}
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: {{ include "hugegraph.hubble.dataName" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: hubble
+ # Survive helm uninstall, matching the PD/Store volumeClaimTemplates
+ # behavior; delete the PVC explicitly to discard the stored connection
+ # metadata and credentials.
+ annotations:
+ helm.sh/resource-policy: keep
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: {{ .Values.hubble.persistence.size }}
+ {{- if eq .Values.hubble.persistence.storageClassName "-" }}
+ storageClassName: ""
+ {{- else if .Values.hubble.persistence.storageClassName }}
+ storageClassName: {{ .Values.hubble.persistence.storageClassName | quote }}
+ {{- end }}
+{{- end }}
diff --git a/helm/hugegraph/templates/hubble-service.yaml b/helm/hugegraph/templates/hubble-service.yaml
new file mode 100644
index 0000000000..a6210f5a72
--- /dev/null
+++ b/helm/hugegraph/templates/hubble-service.yaml
@@ -0,0 +1,54 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- if get (get .Values "hubble" | default dict) "enabled" | default false }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "hugegraph.hubble.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: hubble
+ {{- $svc := get .Values.hubble "service" | default dict }}
+ {{- with (get $svc "annotations") }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ type: {{ get $svc "type" | default "ClusterIP" }}
+ {{- range $field := list "externalTrafficPolicy" "loadBalancerClass" "loadBalancerIP" "loadBalancerSourceRanges" "externalIPs" }}
+ {{- with get $svc $field }}
+ {{ $field }}: {{ toYaml . | nindent 4 }}
+ {{- end }}
+ {{- end }}
+ {{- if hasKey $svc "allocateLoadBalancerNodePorts" }}
+ {{- if ne (get $svc "allocateLoadBalancerNodePorts") nil }}
+ allocateLoadBalancerNodePorts: {{ get $svc "allocateLoadBalancerNodePorts" }}
+ {{- end }}
+ {{- end }}
+ selector:
+ {{- include "hugegraph.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: hubble
+ ports:
+ - name: http
+ port: {{ .Values.hubble.port }}
+ targetPort: http
+ {{- with (get $svc "nodePort") }}
+ nodePort: {{ . }}
+ {{- end }}
+{{- end }}
diff --git a/helm/hugegraph/templates/networkpolicy.yaml b/helm/hugegraph/templates/networkpolicy.yaml
new file mode 100644
index 0000000000..e8638d6d74
--- /dev/null
+++ b/helm/hugegraph/templates/networkpolicy.yaml
@@ -0,0 +1,199 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- /*
+One NetworkPolicy per component. Each object isolates its own Pods in both
+directions and carries all of their allows, so no object ever denies a Pod
+before that Pod's allows exist, whatever order the objects are applied in.
+Peers match the release and component labels; ports are the values the
+containers bind. The helm test Pod is selected by no policy, so its egress
+stays open and the Server policy admits it. Outside clients require native extraIngress rules. An exposed Service or
+Ingress does not automatically change these rules.
+*/}}
+{{- $np := get .Values "networkPolicy" | default dict }}
+{{- if get $np "enabled" }}
+{{- $pd := .Values.pd.ports }}
+{{- $store := .Values.store.ports }}
+{{- $serverPort := .Values.server.port }}
+{{- $hubbleEnabled := .Values.hubble.enabled }}
+{{- $hubblePd := and $hubbleEnabled (eq .Values.hubble.mode "pd") }}
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: {{ include "hugegraph.pd.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: pd
+spec:
+ podSelector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: pd
+ policyTypes:
+ - Ingress
+ - Egress
+ ingress:
+ - from:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "pd") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $pd.raft $pd.grpc) | nindent 8 }}
+ - from:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "store") | nindent 8 }}
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "server") | nindent 8 }}
+ {{- if $hubblePd }}
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "hubble") | nindent 8 }}
+ {{- end }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $pd.grpc $pd.rest) | nindent 8 }}
+ {{- with get (get $np "pd" | default dict) "extraIngress" }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ egress:
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "pd") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $pd.raft $pd.grpc) | nindent 8 }}
+ {{- include "hugegraph.netpol.dns" . | nindent 4 }}
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: {{ include "hugegraph.store.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: store
+spec:
+ podSelector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: store
+ policyTypes:
+ - Ingress
+ - Egress
+ ingress:
+ - from:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "store") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $store.raft) | nindent 8 }}
+ - from:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "server") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $store.grpc $store.rest) | nindent 8 }}
+ {{- if $hubblePd }}
+ - from:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "hubble") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $store.rest) | nindent 8 }}
+ {{- end }}
+ {{- with get (get $np "store" | default dict) "extraIngress" }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ egress:
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "pd") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $pd.grpc $pd.rest) | nindent 8 }}
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "store") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $store.raft) | nindent 8 }}
+ {{- include "hugegraph.netpol.dns" . | nindent 4 }}
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: {{ include "hugegraph.server.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+spec:
+ podSelector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: server
+ policyTypes:
+ - Ingress
+ - Egress
+ ingress:
+ - from:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "test") | nindent 8 }}
+ {{- if $hubbleEnabled }}
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "hubble") | nindent 8 }}
+ {{- end }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $serverPort) | nindent 8 }}
+ {{- with get (get $np "server" | default dict) "extraIngress" }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ egress:
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "pd") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $pd.grpc $pd.rest) | nindent 8 }}
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "store") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $store.grpc $store.rest) | nindent 8 }}
+ {{- include "hugegraph.netpol.dns" . | nindent 4 }}
+{{- if $hubbleEnabled }}
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: {{ include "hugegraph.hubble.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: hubble
+spec:
+ podSelector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: hubble
+ policyTypes:
+ - Ingress
+ - Egress
+ {{- with get (get $np "hubble" | default dict) "extraIngress" }}
+ ingress:
+ {{- toYaml . | nindent 4 }}
+ {{- else }}
+ # No ingress rules under policyTypes Ingress admits nothing. The key is left
+ # out rather than rendered empty, because the API server drops an empty list
+ # and Helm would then rewrite the object on every upgrade. kubectl
+ # port-forward reaches the Pod over loopback, which no policy filters.
+ {{- end }}
+ egress:
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "server") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $serverPort) | nindent 8 }}
+ {{- if $hubblePd }}
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "pd") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $pd.grpc $pd.rest) | nindent 8 }}
+ - to:
+ {{- include "hugegraph.netpol.peer" (dict "root" . "component" "store") | nindent 8 }}
+ ports:
+ {{- include "hugegraph.netpol.ports" (list $store.rest) | nindent 8 }}
+ {{- end }}
+ {{- include "hugegraph.netpol.dns" . | nindent 4 }}
+ {{- with get (get $np "hubble" | default dict) "extraEgress" }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+{{- end }}
+{{- end }}
diff --git a/helm/hugegraph/templates/pd-auth-secret.yaml b/helm/hugegraph/templates/pd-auth-secret.yaml
new file mode 100644
index 0000000000..a699447c49
--- /dev/null
+++ b/helm/hugegraph/templates/pd-auth-secret.yaml
@@ -0,0 +1,33 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $auth := get .Values.pd "auth" | default dict -}}
+{{- $autoGen := ternary (get $auth "autoGenerate") true (hasKey $auth "autoGenerate") -}}
+{{- if and (not (get $auth "existingSecret" | default "")) (or (get $auth "value" | default "") $autoGen) }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "hugegraph.pd.authSecretName" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ annotations:
+ helm.sh/resource-policy: keep
+type: Opaque
+data:
+ {{ include "hugegraph.pd.authSecretKey" . | quote }}: {{ include "hugegraph.pd.authSecretValue" . | quote }}
+{{- end }}
diff --git a/helm/hugegraph/templates/pd-pdb.yaml b/helm/hugegraph/templates/pd-pdb.yaml
new file mode 100644
index 0000000000..492fad2ca1
--- /dev/null
+++ b/helm/hugegraph/templates/pd-pdb.yaml
@@ -0,0 +1,33 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- if and .Values.pd.pdb.enabled (gt (int .Values.pd.replicas) 1) }}
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: {{ include "hugegraph.pd.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: pd
+spec:
+ minAvailable: {{ .Values.pd.pdb.minAvailable }}
+ selector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: pd
+{{- end }}
diff --git a/helm/hugegraph/templates/pd-service-client.yaml b/helm/hugegraph/templates/pd-service-client.yaml
new file mode 100644
index 0000000000..f9d1373836
--- /dev/null
+++ b/helm/hugegraph/templates/pd-service-client.yaml
@@ -0,0 +1,58 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $svc := get .Values.pd "service" | default dict }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "hugegraph.pd.clientName" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: pd
+ {{- with (get $svc "annotations") }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ type: {{ get $svc "type" | default "ClusterIP" }}
+ {{- range $field := list "externalTrafficPolicy" "loadBalancerClass" "loadBalancerIP" "loadBalancerSourceRanges" "externalIPs" }}
+ {{- with get $svc $field }}
+ {{ $field }}: {{ toYaml . | nindent 4 }}
+ {{- end }}
+ {{- end }}
+ {{- if hasKey $svc "allocateLoadBalancerNodePorts" }}
+ {{- if ne (get $svc "allocateLoadBalancerNodePorts") nil }}
+ allocateLoadBalancerNodePorts: {{ get $svc "allocateLoadBalancerNodePorts" }}
+ {{- end }}
+ {{- end }}
+ selector:
+ {{- include "hugegraph.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: pd
+ ports:
+ - name: rest
+ port: {{ .Values.pd.ports.rest }}
+ targetPort: rest
+ {{- with (get $svc "restNodePort") }}
+ nodePort: {{ . }}
+ {{- end }}
+ - name: grpc
+ port: {{ .Values.pd.ports.grpc }}
+ targetPort: grpc
+ {{- with (get $svc "grpcNodePort") }}
+ nodePort: {{ . }}
+ {{- end }}
diff --git a/helm/hugegraph/templates/pd-service-headless.yaml b/helm/hugegraph/templates/pd-service-headless.yaml
new file mode 100644
index 0000000000..5a42745811
--- /dev/null
+++ b/helm/hugegraph/templates/pd-service-headless.yaml
@@ -0,0 +1,42 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "hugegraph.pd.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: pd
+spec:
+ clusterIP: None
+ # Mandatory: Raft bootstrap needs DNS for not-yet-ready pods
+ publishNotReadyAddresses: true
+ selector:
+ {{- include "hugegraph.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: pd
+ ports:
+ - name: grpc
+ port: {{ .Values.pd.ports.grpc }}
+ targetPort: grpc
+ - name: rest
+ port: {{ .Values.pd.ports.rest }}
+ targetPort: rest
+ - name: raft
+ port: {{ .Values.pd.ports.raft }}
+ targetPort: raft
diff --git a/helm/hugegraph/templates/pd-statefulset.yaml b/helm/hugegraph/templates/pd-statefulset.yaml
new file mode 100644
index 0000000000..0546070ac8
--- /dev/null
+++ b/helm/hugegraph/templates/pd-statefulset.yaml
@@ -0,0 +1,184 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: {{ include "hugegraph.pd.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: pd
+spec:
+ serviceName: {{ include "hugegraph.pd.name" . }}
+ replicas: {{ .Values.pd.replicas }}
+ podManagementPolicy: Parallel
+ {{- with .Values.pd.updateStrategy }}
+ updateStrategy:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- if .Values.pd.storage.enabled }}
+ {{- with .Values.pd.persistentVolumeClaimRetentionPolicy }}
+ persistentVolumeClaimRetentionPolicy:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: pd
+ template:
+ metadata:
+ labels:
+ {{- include "hugegraph.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: pd
+ {{- with .Values.pd.podLabels }}{{ toYaml . | nindent 8 }}{{- end }}
+ annotations:
+ # Rolls PD pods when the resolved REST auth Secret changes, so rotating
+ # an existingSecret takes effect without a manual restart.
+ checksum/pd-auth: {{ include "hugegraph.pd.authChecksum" . | quote }}
+ {{- with .Values.pd.podAnnotations }}{{ toYaml . | nindent 8 }}{{- end }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ automountServiceAccountToken: {{ get (get .Values.pd "serviceAccount" | default dict) "automountServiceAccountToken" | default false }}
+ serviceAccountName: {{ include "hugegraph.serviceAccountName" (dict "component" .Values.pd "name" (include "hugegraph.pd.name" .) ) }}
+ {{- if hasKey .Values.pd "terminationGracePeriodSeconds" }}
+ terminationGracePeriodSeconds: {{ .Values.pd.terminationGracePeriodSeconds }}
+ {{- end }}
+ {{- with .Values.pd.priorityClassName }}
+ priorityClassName: {{ . | quote }}
+ {{- end }}
+ {{- with .Values.pd.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.pd.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.pd.topologySpreadConstraints }}
+ topologySpreadConstraints:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.pd.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- if .Values.pd.affinity }}
+ affinity:
+ {{- toYaml .Values.pd.affinity | nindent 8 }}
+ {{- else }}
+ {{- with include "hugegraph.antiAffinity" (dict "mode" .Values.pd.antiAffinity "component" "pd" "labels" (include "hugegraph.selectorLabels" . | fromYaml)) }}{{ . | trim | nindent 6 }}{{- end }}
+ {{- end }}
+ containers:
+ - name: pd
+ image: {{ include "hugegraph.image" (dict "image" .Values.pd.image "appVersion" $.Chart.AppVersion) | quote }}
+ imagePullPolicy: {{ .Values.pd.image.pullPolicy }}
+ {{- with .Values.pd.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ ports:
+ - name: grpc
+ containerPort: {{ .Values.pd.ports.grpc }}
+ - name: rest
+ containerPort: {{ .Values.pd.ports.rest }}
+ - name: raft
+ containerPort: {{ .Values.pd.ports.raft }}
+ env:
+ - name: POD_NAME
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.name
+ - name: NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ - name: HG_PD_GRPC_HOST
+ value: "$(POD_NAME).{{ include "hugegraph.pd.name" . }}.$(NAMESPACE).svc"
+ - name: HG_PD_GRPC_PORT
+ value: {{ .Values.pd.ports.grpc | quote }}
+ - name: HG_PD_REST_PORT
+ value: {{ .Values.pd.ports.rest | quote }}
+ - name: HG_PD_RAFT_ADDRESS
+ value: "$(POD_NAME).{{ include "hugegraph.pd.name" . }}.$(NAMESPACE).svc:{{ .Values.pd.ports.raft }}"
+ - name: HG_PD_RAFT_PEERS_LIST
+ value: {{ include "hugegraph.pd.raftPeersList" . | quote }}
+ - name: HG_PD_INITIAL_STORE_LIST
+ value: {{ include "hugegraph.store.initialStoreList" . | quote }}
+ - name: HG_PD_INITIAL_STORE_COUNT
+ value: {{ .Values.store.replicas | quote }}
+ - name: HG_PD_DATA_PATH
+ value: {{ .Values.pd.dataPath | quote }}
+ - name: HG_PD_AUTH_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "hugegraph.pd.authSecretName" . | quote }}
+ key: {{ include "hugegraph.pd.authSecretKey" . | quote }}
+ {{- with .Values.pd.extraEnv }}{{ toYaml . | nindent 12 }}{{- end }}
+ {{- with include "hugegraph.javaOptsEnv" (include "hugegraph.pd.effectiveJavaOpts" .) }}{{ . | trim | nindent 12 }}{{- end }}
+ volumeMounts:
+ - name: pd-data
+ mountPath: {{ .Values.pd.dataPath }}
+ startupProbe:
+ httpGet:
+ path: {{ include "hugegraph.pd.livenessPath" . }}
+ port: rest
+ failureThreshold: {{ .Values.pd.probes.startup.failureThreshold }}
+ periodSeconds: {{ .Values.pd.probes.startup.periodSeconds }}
+ {{- with include "hugegraph.probeTuning" .Values.pd.probes.startup }}{{ . | trim | nindent 12 }}{{- end }}
+ readinessProbe:
+ httpGet:
+ path: {{ .Values.pd.readinessPath | default "/v1/ready" }}
+ port: rest
+ periodSeconds: {{ .Values.pd.probes.readiness.periodSeconds }}
+ failureThreshold: {{ .Values.pd.probes.readiness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.pd.probes.readiness }}{{ . | trim | nindent 12 }}{{- end }}
+ livenessProbe:
+ httpGet:
+ path: {{ include "hugegraph.pd.livenessPath" . }}
+ port: rest
+ periodSeconds: {{ .Values.pd.probes.liveness.periodSeconds }}
+ failureThreshold: {{ .Values.pd.probes.liveness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.pd.probes.liveness }}{{ . | trim | nindent 12 }}{{- end }}
+ {{- with .Values.pd.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- if not .Values.pd.storage.enabled }}
+ volumes:
+ - name: pd-data
+ emptyDir: {}
+ {{- end }}
+ {{- if .Values.pd.storage.enabled }}
+ volumeClaimTemplates:
+ - metadata:
+ name: pd-data
+ spec:
+ accessModes: ["ReadWriteOnce"]
+ {{- if eq .Values.pd.storage.storageClassName "-" }}
+ storageClassName: ""
+ {{- else if .Values.pd.storage.storageClassName }}
+ storageClassName: {{ .Values.pd.storage.storageClassName | quote }}
+ {{- end }}
+ resources:
+ requests:
+ storage: {{ .Values.pd.storage.size }}
+ {{- end }}
diff --git a/helm/hugegraph/templates/server-auth-token-secret.yaml b/helm/hugegraph/templates/server-auth-token-secret.yaml
new file mode 100644
index 0000000000..340d04571d
--- /dev/null
+++ b/helm/hugegraph/templates/server-auth-token-secret.yaml
@@ -0,0 +1,33 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $token := get $auth "token" | default dict -}}
+{{- if and (get $auth "enabled" | default false) (not (get $token "existingSecret" | default "")) (or (get $token "value" | default "") (get $token "autoGenerate" | default false)) }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "hugegraph.server.authTokenSecretName" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ annotations:
+ helm.sh/resource-policy: keep
+type: Opaque
+data:
+ {{ include "hugegraph.server.authTokenSecretKey" . | quote }}: {{ include "hugegraph.server.authTokenSecretValue" . | quote }}
+{{- end }}
diff --git a/helm/hugegraph/templates/server-deployment.yaml b/helm/hugegraph/templates/server-deployment.yaml
new file mode 100644
index 0000000000..6c10a8d33a
--- /dev/null
+++ b/helm/hugegraph/templates/server-deployment.yaml
@@ -0,0 +1,332 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- include "hugegraph.validateValues" . }}
+{{- $customPort := ne (int .Values.server.port) 8080 }}
+{{/*
+Distributed HStore requires every Server replica to share graph metadata
+through PD. The same registration properties let PD-mode Hubble discover the
+Server and let the built-in authenticator create the admin on the PD path,
+so the config-rewrite wrapper below runs unconditionally.
+*/}}
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "hugegraph.server.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+spec:
+ {{- if not .Values.server.hpa.enabled }}
+ replicas: {{ .Values.server.replicas }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: server
+ template:
+ metadata:
+ labels:
+ {{- include "hugegraph.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: server
+ {{- with .Values.server.podLabels }}{{ toYaml . | nindent 8 }}{{- end }}
+ annotations:
+ {{- if .Values.server.auth.enabled }}
+ # Rolls Server pods when the resolved auth Secrets change, so rotating
+ # an existingSecret takes effect without a manual restart.
+ checksum/auth: {{ include "hugegraph.server.authChecksum" . | quote }}
+ {{- end }}
+ # Rolls Server pods when the PD REST Secret its storage wait uses changes.
+ checksum/pd-auth: {{ include "hugegraph.pd.authChecksum" . | quote }}
+ {{- with .Values.server.podAnnotations }}{{ toYaml . | nindent 8 }}{{- end }}
+ spec:
+ automountServiceAccountToken: {{ get (get .Values.server "serviceAccount" | default dict) "automountServiceAccountToken" | default false }}
+ serviceAccountName: {{ include "hugegraph.serviceAccountName" (dict "component" .Values.server "name" (include "hugegraph.server.name" .) ) }}
+ {{- if hasKey .Values.server "terminationGracePeriodSeconds" }}
+ terminationGracePeriodSeconds: {{ .Values.server.terminationGracePeriodSeconds }}
+ {{- end }}
+ {{- with .Values.server.priorityClassName }}
+ priorityClassName: {{ . | quote }}
+ {{- end }}
+ {{- with .Values.server.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.server.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.server.topologySpreadConstraints }}
+ topologySpreadConstraints:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.server.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- if .Values.server.affinity }}
+ affinity:
+ {{- toYaml .Values.server.affinity | nindent 8 }}
+ {{- else }}
+ {{- with include "hugegraph.antiAffinity" (dict "mode" (.Values.server.antiAffinity | default "preferred") "component" "server" "labels" (include "hugegraph.selectorLabels" . | fromYaml)) }}{{ . | trim | nindent 6 }}{{- end }}
+ {{- end }}
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ - name: server
+ image: {{ include "hugegraph.image" (dict "image" .Values.server.image "appVersion" $.Chart.AppVersion) | quote }}
+ imagePullPolicy: {{ .Values.server.image.pullPolicy }}
+ {{- with .Values.server.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ command:
+ - /usr/bin/dumb-init
+ - --
+ - /bin/bash
+ - -c
+ args:
+ - |
+ set -euo pipefail
+ {{- if .Values.server.auth.enabled }}
+ : "${PASSWORD:?auth Secret key 'password' must not be empty}"
+ # The value is written into a Java properties file, whose parser
+ # treats CR as a line terminator, unescapes backslashes, and
+ # trims whitespace around the value, so these shapes would
+ # inject config lines or silently store a password that differs
+ # from the Secret.
+ case "${PASSWORD}" in
+ *' '*)
+ # Temporary: the image entrypoint writes auth.admin_pa with
+ # each space escaped as a backslash and a space, and the
+ # Server keeps that backslash, so the account gets a
+ # password the Secret does not hold. Drop this case and the
+ # space exclusion in the schema pattern once the image
+ # encoder round-trips spaces (TODO at encode_prop_value in
+ # docker-entrypoint.sh, apache/hugegraph#3260).
+ echo "auth Secret key 'password' must not contain spaces: the" \
+ "Server image stores each space in the admin password with" \
+ "a backslash before it, so the account would answer 401 to" \
+ "the Secret value; fix the server.auth.admin Secret value" >&2
+ exit 1
+ ;;
+ *\\*)
+ echo "auth Secret key 'password' must not contain backslashes;" \
+ "fix the server.auth.admin Secret value" >&2
+ exit 1
+ ;;
+ *:*)
+ echo "auth Secret key 'password' must not contain a colon: the" \
+ "Server splits the decoded Basic-auth credential on every" \
+ "colon and answers 400 to such a password; fix the" \
+ "server.auth.admin Secret value" >&2
+ exit 1
+ ;;
+ esac
+ # Printable ASCII only, checked byte-wise under the C locale so
+ # the image locale cannot widen it. The Server decodes the
+ # Basic-auth header as ASCII, so a non-ASCII password is
+ # accepted here, applied to the admin account, and then answers
+ # 401 to every request made with it. Temporary: drop this check,
+ # the colon case above and the matching schema pattern once the
+ # Server decodes Basic auth as UTF-8 and splits the credential on
+ # its first colon only (apache/hugegraph#3284) in the image this
+ # chart deploys.
+ if (( $(printf '%s' "${PASSWORD}" | LC_ALL=C tr -d '\040-\176' | wc -c) > 0 )); then
+ echo "auth Secret key 'password' must be printable ASCII (bytes" \
+ "0x20-0x7E: no tabs, newlines or non-ASCII characters): the" \
+ "Server decodes Basic-auth credentials as ASCII, so a" \
+ "non-ASCII password would be applied and then rejected" \
+ "with 401 on every request; fix the server.auth.admin" \
+ "Secret value" >&2
+ exit 1
+ fi
+ {{- end }}
+ CONF=./conf/rest-server.properties
+ if [[ ! -r "${CONF}" ]]; then
+ echo "missing or unreadable ${CONF}; refusing to write a stub config" >&2
+ exit 1
+ fi
+ TMP=$(mktemp)
+ FOUND_USE_PD=false
+ FOUND_PD_PEERS=false
+ FOUND_URLS_TO_PD=false
+ FOUND_DEPLOY_IN_K8S=false
+ # An IPv6 POD_IP must be bracketed inside the announcement URL.
+ # Only the chart's own default is rewritten; an explicit
+ # server.advertiseUrl is the operator's to format.
+ if [[ "${POD_IP:-}" == *:* && "${HG_SERVER_URLS_TO_PD:-}" == "http://${POD_IP}:"* ]]; then
+ HG_SERVER_URLS_TO_PD="http://[${POD_IP}]:${HG_SERVER_URLS_TO_PD##*:}"
+ fi
+ {{- if .Values.server.auth.enabled }}
+ FOUND_AUTH_ADMIN_PA=false
+ {{- end }}
+ {{- if $customPort }}
+ FOUND_RESTSERVER_URL=false
+ {{- end }}
+ while IFS= read -r LINE || [[ -n "${LINE}" ]]; do
+ case "${LINE}" in
+ usePD=*)
+ printf 'usePD=true\n' >>"${TMP}"
+ FOUND_USE_PD=true
+ ;;
+ pd.peers=*)
+ printf 'pd.peers=%s\n' "${HG_SERVER_PD_PEERS}" >>"${TMP}"
+ FOUND_PD_PEERS=true
+ ;;
+ server.urls_to_pd=*)
+ printf 'server.urls_to_pd=%s\n' "${HG_SERVER_URLS_TO_PD}" >>"${TMP}"
+ FOUND_URLS_TO_PD=true
+ ;;
+ server.deploy_in_k8s=*)
+ printf 'server.deploy_in_k8s=true\n' >>"${TMP}"
+ FOUND_DEPLOY_IN_K8S=true
+ ;;
+ {{- if .Values.server.auth.enabled }}
+ auth.admin_pa=*)
+ printf 'auth.admin_pa=%s\n' "${PASSWORD}" >>"${TMP}"
+ FOUND_AUTH_ADMIN_PA=true
+ ;;
+ {{- end }}
+ {{- if $customPort }}
+ restserver.url=*)
+ printf 'restserver.url=http://0.0.0.0:%s\n' \
+ {{ .Values.server.port | quote }} >>"${TMP}"
+ FOUND_RESTSERVER_URL=true
+ ;;
+ {{- end }}
+ *)
+ printf '%s\n' "${LINE}" >>"${TMP}"
+ ;;
+ esac
+ done <"${CONF}"
+ if [[ "${FOUND_USE_PD}" == false ]]; then
+ printf 'usePD=true\n' >>"${TMP}"
+ fi
+ if [[ "${FOUND_PD_PEERS}" == false ]]; then
+ printf 'pd.peers=%s\n' "${HG_SERVER_PD_PEERS}" >>"${TMP}"
+ fi
+ # PD hands this URL to discovery clients such as Hubble.
+ # HG_SERVER_URLS_TO_PD uses server.advertiseUrl when set,
+ # otherwise the in-cluster Server Service URL. The k8s
+ # branch is taken only when server.deploy_in_k8s is true;
+ # otherwise the announcement falls back to restserver.url,
+ # whose 0.0.0.0 is never resolvable from another Pod.
+ if [[ "${FOUND_URLS_TO_PD}" == false ]]; then
+ printf 'server.urls_to_pd=%s\n' "${HG_SERVER_URLS_TO_PD}" >>"${TMP}"
+ fi
+ if [[ "${FOUND_DEPLOY_IN_K8S}" == false ]]; then
+ printf 'server.deploy_in_k8s=true\n' >>"${TMP}"
+ fi
+ {{- if .Values.server.auth.enabled }}
+ if [[ "${FOUND_AUTH_ADMIN_PA}" == false ]]; then
+ printf 'auth.admin_pa=%s\n' "${PASSWORD}" >>"${TMP}"
+ fi
+ {{- end }}
+ {{- if $customPort }}
+ if [[ "${FOUND_RESTSERVER_URL}" == false ]]; then
+ printf 'restserver.url=http://0.0.0.0:%s\n' \
+ {{ .Values.server.port | quote }} >>"${TMP}"
+ fi
+ {{- end }}
+ chmod 600 "${TMP}"
+ mv "${TMP}" "${CONF}"
+ exec ./docker-entrypoint.sh
+ ports:
+ - name: http
+ containerPort: {{ .Values.server.port }}
+ env:
+ - name: POD_IP
+ valueFrom:
+ fieldRef:
+ fieldPath: status.podIP
+ - name: HG_SERVER_BACKEND
+ value: {{ .Values.server.backend | quote }}
+ {{- if ne (.Values.server.serviceId | default "DEFAULT") "DEFAULT" }}
+ - name: HG_SERVER_SERVICE_ID
+ value: {{ .Values.server.serviceId | replace "$" "$$" | quote }}
+ {{- end }}
+ {{- if ne (.Values.server.graphSpace | default "DEFAULT") "DEFAULT" }}
+ - name: HG_SERVER_GRAPHSPACE
+ value: {{ .Values.server.graphSpace | quote }}
+ {{- end }}
+ - name: HG_SERVER_PD_PEERS
+ value: {{ include "hugegraph.pd.grpcPeersList" . | quote }}
+ - name: HG_SERVER_PD_REST_ENDPOINT
+ value: {{ include "hugegraph.pd.restPeersList" . | quote }}
+ # wait-storage.sh authenticates its PD readiness checks as the
+ # store service user with this password.
+ - name: PD_AUTH_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "hugegraph.pd.authSecretName" . | quote }}
+ key: {{ include "hugegraph.pd.authSecretKey" . | quote }}
+ - name: STORE_REST
+ value: {{ include "hugegraph.store.restPrimary" . | quote }}
+ - name: HG_SERVER_INIT_STORE_ENABLED
+ value: {{ .Values.server.initStoreEnabled | quote }}
+ # The image would otherwise give the start command 120 seconds,
+ # less than the storage wait, and kill a Server that is still
+ # coming up. Track the startup probe's own budget instead.
+ - name: WAIT_STORAGE_TIMEOUT_S
+ value: {{ .Values.server.storageWaitTimeoutSeconds | quote }}
+ - name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: {{ include "hugegraph.server.startupTimeoutSeconds" . | quote }}
+ - name: HG_SERVER_URLS_TO_PD
+ value: {{ include "hugegraph.server.urlsToPd" . | quote }}
+ {{- with .Values.server.extraEnv }}{{ toYaml . | nindent 12 }}{{- end }}
+ {{- with include "hugegraph.javaOptsEnv" .Values.server.javaOpts }}{{ . | trim | nindent 12 }}{{- end }}
+ {{- if .Values.server.auth.enabled }}
+ - name: PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "hugegraph.server.authSecretName" . | quote }}
+ key: {{ include "hugegraph.server.authSecretKey" . | quote }}
+ - name: HG_SERVER_AUTH_TOKEN_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "hugegraph.server.authTokenSecretName" . | quote }}
+ key: {{ include "hugegraph.server.authTokenSecretKey" . | quote }}
+ {{- end }}
+ startupProbe:
+ httpGet:
+ path: /versions
+ port: http
+ failureThreshold: {{ include "hugegraph.server.startupFailureThreshold" . }}
+ periodSeconds: {{ .Values.server.probes.startup.periodSeconds }}
+ {{- with include "hugegraph.probeTuning" .Values.server.probes.startup }}{{ . | trim | nindent 12 }}{{- end }}
+ readinessProbe:
+ httpGet:
+ path: {{ .Values.server.readinessPath | default "/versions" }}
+ port: http
+ periodSeconds: {{ .Values.server.probes.readiness.periodSeconds }}
+ failureThreshold: {{ .Values.server.probes.readiness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.server.probes.readiness }}{{ . | trim | nindent 12 }}{{- end }}
+ livenessProbe:
+ httpGet:
+ path: /versions
+ port: http
+ periodSeconds: {{ .Values.server.probes.liveness.periodSeconds }}
+ failureThreshold: {{ .Values.server.probes.liveness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.server.probes.liveness }}{{ . | trim | nindent 12 }}{{- end }}
+ {{- with .Values.server.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
diff --git a/helm/hugegraph/templates/server-hpa.yaml b/helm/hugegraph/templates/server-hpa.yaml
new file mode 100644
index 0000000000..0ab64c3d62
--- /dev/null
+++ b/helm/hugegraph/templates/server-hpa.yaml
@@ -0,0 +1,41 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- if .Values.server.hpa.enabled }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ include "hugegraph.server.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: {{ include "hugegraph.server.name" . }}
+ minReplicas: {{ .Values.server.hpa.minReplicas }}
+ maxReplicas: {{ .Values.server.hpa.maxReplicas }}
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.server.hpa.targetCPUUtilizationPercentage }}
+{{- end }}
diff --git a/helm/hugegraph/templates/server-ingress.yaml b/helm/hugegraph/templates/server-ingress.yaml
new file mode 100644
index 0000000000..2f88becd4f
--- /dev/null
+++ b/helm/hugegraph/templates/server-ingress.yaml
@@ -0,0 +1,56 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- if .Values.server.ingress.enabled }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "hugegraph.server.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+ {{- with (get .Values.server.ingress "annotations") }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if .Values.server.ingress.className }}
+ ingressClassName: {{ .Values.server.ingress.className | quote }}
+ {{- end }}
+ {{- with .Values.server.ingress.tls }}
+ tls:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ rules:
+ {{- range (.Values.server.ingress.hosts | default (list (dict "paths" (list (dict "path" "/" "pathType" "Prefix"))))) }}
+ - {{- with .host }}
+ host: {{ . | quote }}
+ {{- end }}
+ http:
+ paths:
+ {{- range (.paths | default (list (dict "path" "/" "pathType" "Prefix"))) }}
+ - path: {{ .path | quote }}
+ pathType: {{ .pathType }}
+ backend:
+ service:
+ name: {{ include "hugegraph.server.name" $ }}
+ port:
+ number: {{ $.Values.server.port }}
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/helm/hugegraph/templates/server-pdb.yaml b/helm/hugegraph/templates/server-pdb.yaml
new file mode 100644
index 0000000000..0fdd176d3a
--- /dev/null
+++ b/helm/hugegraph/templates/server-pdb.yaml
@@ -0,0 +1,35 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $pdb := get .Values.server "pdb" | default dict }}
+{{- $replicaFloor := include "hugegraph.server.replicaFloor" . | int }}
+{{- if and (get $pdb "enabled" | default false) (gt $replicaFloor 1) }}
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: {{ include "hugegraph.server.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+spec:
+ minAvailable: {{ get $pdb "minAvailable" | default 1 }}
+ selector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: server
+{{- end }}
diff --git a/helm/hugegraph/templates/server-secret.yaml b/helm/hugegraph/templates/server-secret.yaml
new file mode 100644
index 0000000000..6b16c661de
--- /dev/null
+++ b/helm/hugegraph/templates/server-secret.yaml
@@ -0,0 +1,33 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $auth := get .Values.server "auth" | default dict -}}
+{{- $admin := get $auth "admin" | default dict -}}
+{{- if and (get $auth "enabled" | default false) (not (get $admin "existingSecret" | default "")) (or (get $admin "password" | default "") (get $admin "autoGenerate" | default false)) }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "hugegraph.server.authSecretName" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ annotations:
+ helm.sh/resource-policy: keep
+type: Opaque
+data:
+ {{ include "hugegraph.server.authSecretKey" . | quote }}: {{ include "hugegraph.server.authSecretPassword" . | quote }}
+{{- end }}
diff --git a/helm/hugegraph/templates/server-service-headless.yaml b/helm/hugegraph/templates/server-service-headless.yaml
new file mode 100644
index 0000000000..6f46957514
--- /dev/null
+++ b/helm/hugegraph/templates/server-service-headless.yaml
@@ -0,0 +1,40 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+# Headless Service enumerating the individual Server Pods. The helm test
+# hook resolves it to run its graph-bound Gremlin check against every Pod
+# (a broken-binding Pod passes readiness, so it appears here), and the
+# per-Pod troubleshooting steps in the README can use it too.
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "hugegraph.server.headlessName" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+spec:
+ clusterIP: None
+ # Deliberately not publishNotReadyAddresses: the checks want exactly the
+ # Pods the client Service routes to.
+ selector:
+ {{- include "hugegraph.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+ ports:
+ - name: http
+ port: {{ .Values.server.port }}
+ targetPort: http
diff --git a/helm/hugegraph/templates/server-service.yaml b/helm/hugegraph/templates/server-service.yaml
new file mode 100644
index 0000000000..f183946e86
--- /dev/null
+++ b/helm/hugegraph/templates/server-service.yaml
@@ -0,0 +1,52 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "hugegraph.server.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+ {{- $svc := get .Values.server "service" | default dict }}
+ {{- with (get $svc "annotations") }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ type: {{ get $svc "type" | default "ClusterIP" }}
+ {{- range $field := list "externalTrafficPolicy" "loadBalancerClass" "loadBalancerIP" "loadBalancerSourceRanges" "externalIPs" }}
+ {{- with get $svc $field }}
+ {{ $field }}: {{ toYaml . | nindent 4 }}
+ {{- end }}
+ {{- end }}
+ {{- if hasKey $svc "allocateLoadBalancerNodePorts" }}
+ {{- if ne (get $svc "allocateLoadBalancerNodePorts") nil }}
+ allocateLoadBalancerNodePorts: {{ get $svc "allocateLoadBalancerNodePorts" }}
+ {{- end }}
+ {{- end }}
+ selector:
+ {{- include "hugegraph.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: server
+ ports:
+ - name: http
+ port: {{ .Values.server.port }}
+ targetPort: http
+ {{- with (get $svc "nodePort") }}
+ nodePort: {{ . }}
+ {{- end }}
diff --git a/helm/hugegraph/templates/serviceaccount.yaml b/helm/hugegraph/templates/serviceaccount.yaml
new file mode 100644
index 0000000000..cb8e08139a
--- /dev/null
+++ b/helm/hugegraph/templates/serviceaccount.yaml
@@ -0,0 +1,40 @@
+{{- /*
+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.
+*/ -}}
+{{ include "hugegraph.resolveValues" . }}
+{{- $components := list "pd" "store" "server" }}
+{{- if get (get .Values "hubble" | default dict) "enabled" | default false }}
+{{- $components = append $components "hubble" }}
+{{- end }}
+{{- range $component := $components }}
+{{- $values := index $.Values $component }}
+{{- $sa := get $values "serviceAccount" | default dict }}
+{{- if and (get $sa "create" | default false) (not (get $sa "name")) }}
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include (printf "hugegraph.%s.name" $component) $ }}
+ labels:
+ {{- include "hugegraph.labels" $ | nindent 4 }}
+ app.kubernetes.io/component: {{ $component }}
+ {{- with (get $sa "annotations") }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+automountServiceAccountToken: {{ get $sa "automountServiceAccountToken" | default false }}
+{{- end }}
+{{- end }}
diff --git a/helm/hugegraph/templates/store-pdb.yaml b/helm/hugegraph/templates/store-pdb.yaml
new file mode 100644
index 0000000000..1b70b7f02e
--- /dev/null
+++ b/helm/hugegraph/templates/store-pdb.yaml
@@ -0,0 +1,33 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- if and .Values.store.pdb.enabled (gt (int .Values.store.replicas) 1) }}
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: {{ include "hugegraph.store.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: store
+spec:
+ minAvailable: {{ .Values.store.pdb.minAvailable }}
+ selector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: store
+{{- end }}
diff --git a/helm/hugegraph/templates/store-service-headless.yaml b/helm/hugegraph/templates/store-service-headless.yaml
new file mode 100644
index 0000000000..4bf790155a
--- /dev/null
+++ b/helm/hugegraph/templates/store-service-headless.yaml
@@ -0,0 +1,42 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "hugegraph.store.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: store
+spec:
+ clusterIP: None
+ # Mandatory: Raft / self-FQDN resolution before readiness
+ publishNotReadyAddresses: true
+ selector:
+ {{- include "hugegraph.selectorLabels" . | nindent 4 }}
+ app.kubernetes.io/component: store
+ ports:
+ - name: grpc
+ port: {{ .Values.store.ports.grpc }}
+ targetPort: grpc
+ - name: raft
+ port: {{ .Values.store.ports.raft }}
+ targetPort: raft
+ - name: rest
+ port: {{ .Values.store.ports.rest }}
+ targetPort: rest
diff --git a/helm/hugegraph/templates/store-statefulset.yaml b/helm/hugegraph/templates/store-statefulset.yaml
new file mode 100644
index 0000000000..81c143ee8a
--- /dev/null
+++ b/helm/hugegraph/templates/store-statefulset.yaml
@@ -0,0 +1,217 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: {{ include "hugegraph.store.name" . }}
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: store
+spec:
+ serviceName: {{ include "hugegraph.store.name" . }}
+ replicas: {{ .Values.store.replicas }}
+ podManagementPolicy: Parallel
+ {{- with .Values.store.updateStrategy }}
+ updateStrategy:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- if .Values.store.storage.enabled }}
+ {{- with .Values.store.persistentVolumeClaimRetentionPolicy }}
+ persistentVolumeClaimRetentionPolicy:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include "hugegraph.selectorLabels" . | nindent 6 }}
+ app.kubernetes.io/component: store
+ template:
+ metadata:
+ labels:
+ {{- include "hugegraph.selectorLabels" . | nindent 8 }}
+ app.kubernetes.io/component: store
+ {{- with .Values.store.podLabels }}{{ toYaml . | nindent 8 }}{{- end }}
+ {{- with .Values.store.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ automountServiceAccountToken: {{ get (get .Values.store "serviceAccount" | default dict) "automountServiceAccountToken" | default false }}
+ serviceAccountName: {{ include "hugegraph.serviceAccountName" (dict "component" .Values.store "name" (include "hugegraph.store.name" .) ) }}
+ {{- if hasKey .Values.store "terminationGracePeriodSeconds" }}
+ terminationGracePeriodSeconds: {{ .Values.store.terminationGracePeriodSeconds }}
+ {{- end }}
+ {{- with .Values.store.priorityClassName }}
+ priorityClassName: {{ . | quote }}
+ {{- end }}
+ {{- with .Values.store.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.store.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.store.topologySpreadConstraints }}
+ topologySpreadConstraints:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.store.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- if .Values.store.affinity }}
+ affinity:
+ {{- toYaml .Values.store.affinity | nindent 8 }}
+ {{- else }}
+ {{- with include "hugegraph.antiAffinity" (dict "mode" .Values.store.antiAffinity "component" "store" "labels" (include "hugegraph.selectorLabels" . | fromYaml)) }}{{ . | trim | nindent 6 }}{{- end }}
+ {{- end }}
+ initContainers:
+ - name: wait-for-pd
+ image: {{ .Values.store.waitImage | quote }}
+ {{- if .Values.store.__waitImageInherited }}
+ imagePullPolicy: {{ .Values.store.image.pullPolicy }}
+ {{- end }}
+ {{- with .Values.store.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ REQUIRED={{ include "hugegraph.pd.quorum" . }}
+ HEALTH_PEERS=$(echo "{{ include "hugegraph.pd.restPeersList" . }}" | tr ',' ' ')
+ TIMEOUT={{ .Values.store.waitTimeoutSeconds | default 900 }}
+ DEADLINE=$(( $(date +%s) + TIMEOUT ))
+ WAIT_PATH={{ .Values.store.waitPath | default "/v1/ready" | quote }}
+ echo "Waiting for ${REQUIRED} PD peers to answer ${WAIT_PATH} among: ${HEALTH_PEERS}"
+ until [ "$(
+ ok=0
+ for peer in ${HEALTH_PEERS}; do
+ if curl -fsS --connect-timeout 2 --max-time 5 "http://${peer}${WAIT_PATH}" >/dev/null 2>&1; then
+ ok=$((ok+1))
+ fi
+ done
+ echo "$ok"
+ )" -ge "${REQUIRED}" ]; do
+ if [ "$(date +%s)" -ge "${DEADLINE}" ]; then
+ echo "Timed out after ${TIMEOUT}s waiting for ${REQUIRED} PD peers to answer ${WAIT_PATH} among: ${HEALTH_PEERS}" >&2
+ echo "Check PD Pods: kubectl get pods -l app.kubernetes.io/component=pd" >&2
+ exit 1
+ fi
+ echo "Waiting for PD peers..."
+ sleep 5
+ done
+ echo "Enough PD peers answered ${WAIT_PATH}."
+ {{- with .Values.store.waitResources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ containers:
+ - name: store
+ image: {{ include "hugegraph.image" (dict "image" .Values.store.image "appVersion" $.Chart.AppVersion) | quote }}
+ imagePullPolicy: {{ .Values.store.image.pullPolicy }}
+ {{- with .Values.store.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ ports:
+ - name: grpc
+ containerPort: {{ .Values.store.ports.grpc }}
+ - name: raft
+ containerPort: {{ .Values.store.ports.raft }}
+ - name: rest
+ containerPort: {{ .Values.store.ports.rest }}
+ env:
+ - name: POD_NAME
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.name
+ - name: NAMESPACE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.namespace
+ - name: HG_STORE_PD_ADDRESS
+ value: {{ include "hugegraph.pd.grpcPeersList" . | quote }}
+ - name: HG_STORE_GRPC_HOST
+ value: "$(POD_NAME).{{ include "hugegraph.store.name" . }}.$(NAMESPACE).svc"
+ - name: HG_STORE_GRPC_PORT
+ value: {{ .Values.store.ports.grpc | quote }}
+ - name: HG_STORE_REST_PORT
+ value: {{ .Values.store.ports.rest | quote }}
+ - name: HG_STORE_RAFT_ADDRESS
+ value: "$(POD_NAME).{{ include "hugegraph.store.name" . }}.$(NAMESPACE).svc:{{ .Values.store.ports.raft }}"
+ - name: HG_STORE_DATA_PATH
+ value: {{ .Values.store.dataPath | quote }}
+ {{- with .Values.store.extraEnv }}{{ toYaml . | nindent 12 }}{{- end }}
+ {{- with include "hugegraph.javaOptsEnv" .Values.store.javaOpts }}{{ . | trim | nindent 12 }}{{- end }}
+ volumeMounts:
+ - name: store-data
+ mountPath: {{ .Values.store.dataPath }}
+ startupProbe:
+ httpGet:
+ path: /v1/health
+ port: rest
+ failureThreshold: {{ .Values.store.probes.startup.failureThreshold }}
+ periodSeconds: {{ .Values.store.probes.startup.periodSeconds }}
+ {{- with include "hugegraph.probeTuning" .Values.store.probes.startup }}{{ . | trim | nindent 12 }}{{- end }}
+ readinessProbe:
+ httpGet:
+ path: /v1/health
+ port: rest
+ periodSeconds: {{ .Values.store.probes.readiness.periodSeconds }}
+ failureThreshold: {{ .Values.store.probes.readiness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.store.probes.readiness }}{{ . | trim | nindent 12 }}{{- end }}
+ livenessProbe:
+ httpGet:
+ path: /v1/health
+ port: rest
+ periodSeconds: {{ .Values.store.probes.liveness.periodSeconds }}
+ failureThreshold: {{ .Values.store.probes.liveness.failureThreshold }}
+ {{- with include "hugegraph.probeTuning" .Values.store.probes.liveness }}{{ . | trim | nindent 12 }}{{- end }}
+ {{- with .Values.store.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- if not .Values.store.storage.enabled }}
+ volumes:
+ - name: store-data
+ emptyDir: {}
+ {{- end }}
+ {{- if .Values.store.storage.enabled }}
+ volumeClaimTemplates:
+ - metadata:
+ name: store-data
+ spec:
+ accessModes: ["ReadWriteOnce"]
+ {{- if eq .Values.store.storage.storageClassName "-" }}
+ storageClassName: ""
+ {{- else if .Values.store.storage.storageClassName }}
+ storageClassName: {{ .Values.store.storage.storageClassName | quote }}
+ {{- end }}
+ resources:
+ requests:
+ storage: {{ .Values.store.storage.size }}
+ {{- end }}
diff --git a/helm/hugegraph/templates/tests/test-connection.yaml b/helm/hugegraph/templates/tests/test-connection.yaml
new file mode 100644
index 0000000000..020c484f6a
--- /dev/null
+++ b/helm/hugegraph/templates/tests/test-connection.yaml
@@ -0,0 +1,186 @@
+#
+# 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.
+#
+
+{{ include "hugegraph.resolveValues" . }}
+{{- $smoke := `def ids = [] as Set;
+(graph.schema().getVertexLabels() + graph.schema().getEdgeLabels()).each {
+ ids.addAll(it.indexLabels())
+};
+ids.each {
+ def index = graph.indexLabel(it);
+ if (!index.status().ok()) {
+ throw new IllegalStateException('Index ' + index.name() + ' is ' +
+ index.status().name() + ' (CREATED expected)')
+ }
+};
+graph.traversal().V().limit(1).count()` }}
+# Official Helm chart test hook — only runs with: helm test
+# Not an install-time hook. Safe with --wait (no init Job in no-init design).
+apiVersion: v1
+kind: Pod
+metadata:
+ name: "{{ include "hugegraph.test.name" . }}"
+ labels:
+ {{- include "hugegraph.labels" . | nindent 4 }}
+ app.kubernetes.io/component: test
+ annotations:
+ "helm.sh/hook": test
+ # Not hook-succeeded: Helm deletes a succeeded hook Pod before
+ # `helm test --logs` reads it, which turns a passing release check into
+ # exit 1 with "pods not found". The completed Pod stays until the next
+ # run replaces it, so both forms exit 0 and a failed Pod keeps its logs.
+ "helm.sh/hook-delete-policy": before-hook-creation
+spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ automountServiceAccountToken: false
+ restartPolicy: Never
+ securityContext:
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: curl
+ image: {{ .Values.server.waitImage | quote }}
+ {{- if .Values.server.__waitImageInherited }}
+ imagePullPolicy: {{ .Values.server.image.pullPolicy }}
+ {{- end }}
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ readOnlyRootFilesystem: true
+ runAsGroup: 101
+ runAsNonRoot: true
+ runAsUser: 100
+ {{- with .Values.server.testResources }}
+ resources:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- if .Values.server.auth.enabled }}
+ env:
+ - name: PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "hugegraph.server.authSecretName" . | quote }}
+ key: {{ include "hugegraph.server.authSecretKey" . | quote }}
+ {{- end }}
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ SVC="{{ include "hugegraph.server.name" . }}"
+ HEADLESS="{{ include "hugegraph.server.headlessName" . }}"
+ PORT="{{ .Values.server.port }}"
+ FLOOR="{{ include "hugegraph.server.replicaFloor" . }}"
+ DEADLINE_S="${HG_TEST_DEADLINE_S:-150}"
+ BODY='{{ dict "gremlin" $smoke "aliases" (dict "graph" (.Values.server.testGraphAlias | default "DEFAULT-hugegraph")) | toJson | replace "'" "'\"'\"'" }}'
+ {{- if .Values.server.testGremlin }}
+ BUSINESS_BODY='{{ dict "gremlin" .Values.server.testGremlin "aliases" (dict "graph" (.Values.server.testGraphAlias | default "DEFAULT-hugegraph")) | toJson | replace "'" "'\"'\"'" }}'
+ {{- else }}
+ BUSINESS_BODY=""
+ {{- end }}
+ {{- if .Values.server.auth.enabled }}
+ AUTH_HEADER=$(printf 'admin:%s' "${PASSWORD}" | base64 | tr -d '\r\n')
+ request() {
+ printf 'header = "Authorization: Basic %s"\n' "${AUTH_HEADER}" |
+ curl --config - -fsS --connect-timeout 3 --max-time 20 "$1"
+ }
+ gremlin() {
+ printf 'header = "Authorization: Basic %s"\n' "${AUTH_HEADER}" |
+ curl --config - -sS --compressed --connect-timeout 3 --max-time 20 \
+ -H 'Content-Type: application/json' -X POST -d "$2" \
+ -w '\n%{http_code}\n' "$1" 2>&1
+ }
+ {{- else }}
+ request() {
+ curl -fsS --connect-timeout 3 --max-time 20 "$1"
+ }
+ gremlin() {
+ curl -sS --compressed --connect-timeout 3 --max-time 20 \
+ -H 'Content-Type: application/json' -X POST -d "$2" \
+ -w '\n%{http_code}\n' "$1" 2>&1
+ }
+ {{- end }}
+ check_gremlin() {
+ OUT=$(gremlin "$1" "$3") || true
+ CODE=$(printf '%s\n' "${OUT}" | grep -E '^[0-9][0-9][0-9]$' | tail -1) || CODE=""
+ if [ "${CODE}" = "200" ]; then
+ echo "helm test: $2 OK on $1"
+ return 0
+ fi
+ printf 'helm test: %s failed on %s code=%s body: %.400s\n' \
+ "$2" "$1" "${CODE:-no-response}" "${OUT}"
+ return 1
+ }
+ echo "helm test: GET http://${SVC}:${PORT}/versions"
+ request "http://${SVC}:${PORT}/versions"
+ echo
+ echo "helm test: GET http://${SVC}:${PORT}/graphs"
+ request "http://${SVC}:${PORT}/graphs"
+ echo
+ # A Server that started while PD was rolling can pass readiness and
+ # serve REST while every Gremlin call on it fails, so the Service
+ # checks above cannot see a bad replica. Resolve the headless
+ # Service to the individual Ready Pods and require a graph-bound
+ # Gremlin answer from every one of them, and from at least FLOOR
+ # Pods, retrying until the deadline so a transient PD election is
+ # not misread as a broken Pod.
+ echo "helm test: Gremlin on every Server Pod via ${HEADLESS} (floor ${FLOOR})"
+ if [ -z "${BUSINESS_BODY}" ]; then
+ echo "helm test: business assertions skipped (server.testGremlin is empty)"
+ fi
+ START=$(date +%s)
+ while :; do
+ # The headless Service answers with every Ready Pod's addresses,
+ # one per address family on a dual-stack cluster, so counting
+ # addresses would count each Pod twice and let two Pods satisfy
+ # a floor of three. Keep one family per Pod: IPv4 when any Pod
+ # publishes one, otherwise IPv6.
+ ALL=$(getent ahosts "${HEADLESS}" 2>/dev/null | awk '{print $1}' | sort -u) || ALL=""
+ IPS=$(printf '%s\n' ${ALL} | grep -v ':' || true)
+ if [ -z "${IPS}" ]; then
+ IPS=$(printf '%s\n' ${ALL} | grep ':' || true)
+ fi
+ COUNT=$(printf '%s\n' ${IPS} | grep -c . || true)
+ # Every attempt is a complete sweep. A Pod that answered in an
+ # earlier attempt is queried again, so the test passes only when
+ # one attempt sees every Pod healthy at the same time, never by
+ # adding up successes from different attempts.
+ FAILED=""
+ for IP in ${IPS}; do
+ case "${IP}" in *:*) TARGET="[${IP}]" ;; *) TARGET="${IP}" ;; esac
+ URL="http://${TARGET}:${PORT}/gremlin"
+ if ! check_gremlin "${URL}" "Gremlin binding/index-state" "${BODY}"; then
+ FAILED="${FAILED}${IP}(${CODE:-no-response}) "
+ elif [ -n "${BUSINESS_BODY}" ] &&
+ ! check_gremlin "${URL}" "business assertion" "${BUSINESS_BODY}"; then
+ FAILED="${FAILED}${IP}(${CODE:-no-response}) "
+ fi
+ done
+ if [ -z "${FAILED}" ] && [ "${COUNT}" -ge "${FLOOR}" ]; then
+ echo "helm test: OK (${COUNT} Server Pods, all configured checks passed)"
+ exit 0
+ fi
+ if [ $(( $(date +%s) - START )) -ge "${DEADLINE_S}" ]; then
+ echo "helm test: FAILED after ${DEADLINE_S}s: pods=${COUNT} floor=${FLOOR} failing=${FAILED:-none}" >&2
+ exit 1
+ fi
+ sleep 5
+ done
diff --git a/helm/hugegraph/testdata/values-pre-hardening.yaml b/helm/hugegraph/testdata/values-pre-hardening.yaml
new file mode 100644
index 0000000000..990f5fe7e0
--- /dev/null
+++ b/helm/hugegraph/testdata/values-pre-hardening.yaml
@@ -0,0 +1,137 @@
+#
+# 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.
+#
+
+# Values shape shipped before the production-hardening fields were added.
+# validate-chart.sh installs this as the chart default values to emulate a
+# Helm --reuse-values upgrade whose stored release values lack all new keys.
+
+fullnameOverride: ""
+nameOverride: ""
+
+imagePullSecrets: []
+
+pd:
+ replicas: 3
+ image:
+ repository: hugegraph/pd
+ tag: latest
+ pullPolicy: IfNotPresent
+ ports:
+ grpc: 8686
+ rest: 8620
+ raft: 8610
+ dataPath: /hugegraph-pd/pd_data
+ storage:
+ size: 10Gi
+ storageClassName: ""
+ resources: {}
+ antiAffinity: required
+ pdb:
+ enabled: true
+ minAvailable: 2
+ probes:
+ startup:
+ failureThreshold: 30
+ periodSeconds: 10
+ readiness:
+ periodSeconds: 10
+ failureThreshold: 3
+ liveness:
+ periodSeconds: 20
+ failureThreshold: 3
+
+store:
+ replicas: 3
+ image:
+ repository: hugegraph/store
+ tag: latest
+ pullPolicy: IfNotPresent
+ ports:
+ grpc: 8500
+ raft: 8510
+ rest: 8520
+ dataPath: /hugegraph-store/storage
+ storage:
+ size: 50Gi
+ storageClassName: ""
+ resources: {}
+ antiAffinity: required
+ pdb:
+ enabled: true
+ minAvailable: 2
+ waitImage: curlimages/curl:8.5.0
+ probes:
+ startup:
+ failureThreshold: 40
+ periodSeconds: 10
+ readiness:
+ periodSeconds: 10
+ failureThreshold: 3
+ liveness:
+ periodSeconds: 20
+ failureThreshold: 3
+
+server:
+ replicas: 3
+ image:
+ repository: hugegraph/server
+ tag: latest
+ pullPolicy: IfNotPresent
+ port: 8080
+ backend: hstore
+ resources: {}
+ waitImage: curlimages/curl:8.5.0
+ initStoreEnabled: false
+ auth:
+ enabled: false
+ admin:
+ password: ""
+ existingSecret: ""
+ key: password
+ autoGenerate: true
+ token:
+ value: ""
+ existingSecret: ""
+ key: token_secret
+ autoGenerate: true
+ ingress:
+ enabled: false
+ className: ""
+ hosts:
+ - host: hugegraph.local
+ paths:
+ - path: /
+ pathType: Prefix
+ tls: []
+ hpa:
+ enabled: false
+ minReplicas: 3
+ maxReplicas: 10
+ targetCPUUtilizationPercentage: 70
+ probes:
+ startup:
+ failureThreshold: 30
+ periodSeconds: 5
+ readiness:
+ periodSeconds: 10
+ failureThreshold: 3
+ liveness:
+ periodSeconds: 20
+ failureThreshold: 3
+
+networkPolicy:
+ enabled: false
diff --git a/helm/hugegraph/tests/__snapshot__/networkpolicy_test.yaml.snap b/helm/hugegraph/tests/__snapshot__/networkpolicy_test.yaml.snap
new file mode 100644
index 0000000000..a79353de84
--- /dev/null
+++ b/helm/hugegraph/tests/__snapshot__/networkpolicy_test.yaml.snap
@@ -0,0 +1,286 @@
+#
+# 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.
+#
+
+matches the reviewed cluster plus Hubble render:
+ 1: |
+ apiVersion: networking.k8s.io/v1
+ kind: NetworkPolicy
+ metadata:
+ labels:
+ app.kubernetes.io/component: pd
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/managed-by: Helm
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/version: latest
+ helm.sh/chart: hugegraph-helm-0.2.0
+ name: RELEASE-NAME-hugegraph-pd
+ spec:
+ egress:
+ - ports:
+ - port: 8610
+ protocol: TCP
+ - port: 8686
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: pd
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 53
+ protocol: UDP
+ - port: 53
+ protocol: TCP
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: pd
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ ports:
+ - port: 8610
+ protocol: TCP
+ - port: 8686
+ protocol: TCP
+ - from:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: store
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: server
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: hubble
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ ports:
+ - port: 8686
+ protocol: TCP
+ - port: 8620
+ protocol: TCP
+ podSelector:
+ matchLabels:
+ app.kubernetes.io/component: pd
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ policyTypes:
+ - Ingress
+ - Egress
+ 2: |
+ apiVersion: networking.k8s.io/v1
+ kind: NetworkPolicy
+ metadata:
+ labels:
+ app.kubernetes.io/component: store
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/managed-by: Helm
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/version: latest
+ helm.sh/chart: hugegraph-helm-0.2.0
+ name: RELEASE-NAME-hugegraph-store
+ spec:
+ egress:
+ - ports:
+ - port: 8686
+ protocol: TCP
+ - port: 8620
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: pd
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 8510
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: store
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 53
+ protocol: UDP
+ - port: 53
+ protocol: TCP
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: store
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ ports:
+ - port: 8510
+ protocol: TCP
+ - from:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: server
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ ports:
+ - port: 8500
+ protocol: TCP
+ - port: 8520
+ protocol: TCP
+ - from:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: hubble
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ ports:
+ - port: 8520
+ protocol: TCP
+ podSelector:
+ matchLabels:
+ app.kubernetes.io/component: store
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ policyTypes:
+ - Ingress
+ - Egress
+ 3: |
+ apiVersion: networking.k8s.io/v1
+ kind: NetworkPolicy
+ metadata:
+ labels:
+ app.kubernetes.io/component: server
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/managed-by: Helm
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/version: latest
+ helm.sh/chart: hugegraph-helm-0.2.0
+ name: RELEASE-NAME-hugegraph-server
+ spec:
+ egress:
+ - ports:
+ - port: 8686
+ protocol: TCP
+ - port: 8620
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: pd
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 8500
+ protocol: TCP
+ - port: 8520
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: store
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 53
+ protocol: UDP
+ - port: 53
+ protocol: TCP
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: test
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: hubble
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ ports:
+ - port: 8080
+ protocol: TCP
+ podSelector:
+ matchLabels:
+ app.kubernetes.io/component: server
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ policyTypes:
+ - Ingress
+ - Egress
+ 4: |
+ apiVersion: networking.k8s.io/v1
+ kind: NetworkPolicy
+ metadata:
+ labels:
+ app.kubernetes.io/component: hubble
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/managed-by: Helm
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/version: latest
+ helm.sh/chart: hugegraph-helm-0.2.0
+ name: RELEASE-NAME-hugegraph-hubble
+ spec:
+ egress:
+ - ports:
+ - port: 8080
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: server
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 8686
+ protocol: TCP
+ - port: 8620
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: pd
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 8520
+ protocol: TCP
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/component: store
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ - ports:
+ - port: 53
+ protocol: UDP
+ - port: 53
+ protocol: TCP
+ podSelector:
+ matchLabels:
+ app.kubernetes.io/component: hubble
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/name: hugegraph
+ policyTypes:
+ - Ingress
+ - Egress
diff --git a/helm/hugegraph/tests/auth_checksum_lookup_test.yaml b/helm/hugegraph/tests/auth_checksum_lookup_test.yaml
new file mode 100644
index 0000000000..5a8a98e7b3
--- /dev/null
+++ b/helm/hugegraph/tests/auth_checksum_lookup_test.yaml
@@ -0,0 +1,148 @@
+#
+# 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.
+#
+
+# The membership and identity guards read the live StatefulSets through
+# lookup. These cases fake that object, so the refused path (a raft port
+# change, a rename, a replica shrink) and the allowed path (an unchanged
+# upgrade, a fresh install) are both pinned at render time. validateValues
+# is included from the Server Deployment, so the refusals render that.
+# The lookup half of the rollout checksums, rendered against fake live
+# Secrets. A chart-generated Secret contributes the digest of its data, so
+# a render that finds the Secret (every upgrade) equals the install render
+# (which hashes the value it writes): no roll on a no-change upgrade. An
+# operator-supplied existingSecret contributes its resourceVersion. Each
+# expected value is sha256 of the parts joined with "|", computed outside
+# Helm, as in auth_checksum_source_test.yaml.
+suite: Rollout checksum lookup half against live Secrets
+templates:
+ - pd-statefulset.yaml
+ - server-deployment.yaml
+kubernetesProvider:
+ scheme:
+ "v1/Secret":
+ gvr:
+ version: v1
+ resource: secrets
+ namespaced: true
+ # The identity guards list StatefulSets; the fake client registers the
+ # list kind only for kinds that have an object, so one StatefulSet of
+ # another release is faked (which the guards must ignore).
+ "apps/v1/StatefulSet":
+ gvr:
+ group: apps
+ version: v1
+ resource: statefulsets
+ namespaced: true
+ objects:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: other-hugegraph-pd
+ namespace: NAMESPACE
+ labels:
+ app.kubernetes.io/instance: other
+ app.kubernetes.io/component: pd
+ spec:
+ replicas: "1"
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: RELEASE-NAME-pd-auth
+ namespace: NAMESPACE
+ resourceVersion: "41"
+ data:
+ secret-key: cGQtc2VjcmV0LXN0ZWFkeQ==
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: byo-pd-secret
+ namespace: NAMESPACE
+ resourceVersion: "100"
+ data:
+ secret-key: b3BlcmF0b3ItY2hvc2Vu
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: RELEASE-NAME-admin
+ namespace: NAMESPACE
+ resourceVersion: "42"
+ data:
+ password: YWRtaW4tc3RlYWR5
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: RELEASE-NAME-auth-token
+ namespace: NAMESPACE
+ resourceVersion: "43"
+ data:
+ token_secret: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
+ - apiVersion: v1
+ kind: Secret
+ metadata:
+ name: byo-admin
+ namespace: NAMESPACE
+ resourceVersion: "7"
+ data:
+ password: b3BlcmF0b3ItYWRtaW4=
+tests:
+ - it: hashes the live generated PD Secret by its value, not its resourceVersion
+ template: pd-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+ value: c28abf86b4f58b426476d7d7d95dc9bc0aee3f87d64616b5314a823eb3285328
+
+ - it: renders the same PD checksum when the inline value equals the live value
+ template: pd-statefulset.yaml
+ set:
+ pd.auth.value: pd-secret-steady
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+ value: c28abf86b4f58b426476d7d7d95dc9bc0aee3f87d64616b5314a823eb3285328
+
+ - it: hashes an operator-supplied PD Secret by its resourceVersion only
+ template: pd-statefulset.yaml
+ set:
+ pd.auth.existingSecret: byo-pd-secret
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+ value: c59f1600408fcc703d7ba56aafaa1a3154380c4c9f97f422a603f79139661ec3
+
+ - it: keeps the generated PD value in the Secret and out of the annotation
+ template: pd-statefulset.yaml
+ asserts:
+ - notMatchRegex:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+ pattern: "pd-secret-steady|cGQtc2VjcmV0LXN0ZWFkeQ=="
+
+ - it: hashes both live generated Server Secrets by value
+ template: server-deployment.yaml
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ value: d5e9d6f7d7bdafc092bd65571b124c37b6aefdf5af871a1b5c4f999131e6e329
+
+ - it: mixes an operator-supplied admin Secret resourceVersion with the generated token digest
+ template: server-deployment.yaml
+ set:
+ server.auth.admin.existingSecret: byo-admin
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ value: 36d51dce49f4d417c0bad64d99f128a4f7498633dba0486ad6eb432c61196f0d
diff --git a/helm/hugegraph/tests/auth_checksum_source_test.yaml b/helm/hugegraph/tests/auth_checksum_source_test.yaml
new file mode 100644
index 0000000000..7403504328
--- /dev/null
+++ b/helm/hugegraph/tests/auth_checksum_source_test.yaml
@@ -0,0 +1,89 @@
+#
+# 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.
+#
+
+# Each rollout checksum picks its revision input by credential source. These
+# cases pin the exact digest, so they fail if the parts list changes at all,
+# not merely if it stops being 64 hex characters. Each expected value is
+# sha256 of the parts joined with "|", computed outside Helm:
+#
+# inline PD value sha256("t-pd-auth|secret-key|" + sha256(value))
+# PD existingSecret sha256("byo-pd-secret|secret-key")
+#
+# The existingSecret cases are the ones that prove the selection: the inline
+# value is set there too, and it must not reach the annotation, because the
+# Pods do not read it.
+#
+# These cases cover the inline half; auth_checksum_lookup_test.yaml renders
+# the lookup half against fake live Secrets (generated: value digest;
+# existingSecret: resourceVersion). The live rollout counts belong to the
+# lifecycle run.
+suite: Rollout checksum revision source
+release:
+ name: t
+tests:
+ - it: hashes the inline PD value with the Secret name and key, and nothing else
+ template: pd-statefulset.yaml
+ set:
+ pd.auth.value: pd-secret-steady
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+ value: c3e18a4d518ea495ef75e4fec03a68675b23ef0be93714e7a777fbb50a5957c1
+
+ - it: drops the inline PD digest once an existingSecret supplies the credential
+ template: pd-statefulset.yaml
+ set:
+ pd.auth.existingSecret: byo-pd-secret
+ pd.auth.value: ignored-by-the-pods
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+ value: 86fdc89040752c37324032343cd1d9a53ca23a46f724689dd61c2f5f829c0d33
+
+ - it: hashes both inline Server credentials with their Secret names and keys
+ template: server-deployment.yaml
+ set:
+ server.auth.admin.password: admin-after
+ server.auth.token.value: 0123456789abcdef0123456789abcdef
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ value: d6ce6518e8b740a46bbacf0b55e4094580048c87912c85f21448b701f972fd72
+
+ - it: selects the admin and token sources independently
+ template: server-deployment.yaml
+ set:
+ server.auth.admin.existingSecret: byo-admin
+ server.auth.admin.password: ignored-by-the-pods
+ server.auth.token.value: 0123456789abcdef0123456789abcdef
+ asserts:
+ - equal:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ value: 7f13191bccb01ef3b2c6ffcce2fc6b6a73a6c6416583dcc4c593a5cfde1a24a0
+
+ - it: keeps credential plaintext out of the annotation
+ template: server-deployment.yaml
+ set:
+ server.auth.admin.password: admin-plaintext-value
+ server.auth.token.value: 0123456789abcdef0123456789abcdef
+ asserts:
+ - notMatchRegex:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ pattern: admin-plaintext-value
+ - notMatchRegex:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ pattern: 0123456789abcdef0123456789abcdef
diff --git a/helm/hugegraph/tests/auth_token_length_test.yaml b/helm/hugegraph/tests/auth_token_length_test.yaml
new file mode 100644
index 0000000000..0ca92a38c5
--- /dev/null
+++ b/helm/hugegraph/tests/auth_token_length_test.yaml
@@ -0,0 +1,38 @@
+#
+# 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.
+#
+
+suite: Auth token length guard
+templates:
+ - server-deployment.yaml
+tests:
+ - it: accepts an empty token value, which defers to autoGenerate
+ asserts:
+ - hasDocuments:
+ count: 1
+
+ - it: accepts a signing key of at least 32 characters
+ set:
+ server.auth.token.value: ktestJwtSigningKey2026xyzABCDEFGH12345678
+ asserts:
+ - hasDocuments:
+ count: 1
+
+ - it: rejects a signing key shorter than 32 characters
+ set:
+ server.auth.token.value: tooshortkey
+ asserts:
+ - failedTemplate: {}
diff --git a/helm/hugegraph/tests/cluster_preset_resources_test.yaml b/helm/hugegraph/tests/cluster_preset_resources_test.yaml
new file mode 100644
index 0000000000..04a59b0c57
--- /dev/null
+++ b/helm/hugegraph/tests/cluster_preset_resources_test.yaml
@@ -0,0 +1,54 @@
+#
+# 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.
+#
+
+suite: Cluster preset Store memory
+values:
+ - ../values-cluster.yaml
+tests:
+ - it: gives the Store room for the caches the image commits outside its heap
+ template: store-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].resources.limits.memory
+ value: 8Gi
+ - equal:
+ path: spec.template.spec.containers[0].resources.requests.memory
+ value: 5Gi
+
+ - it: keeps the Store heap flags the memory budget is derived from
+ template: store-statefulset.yaml
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="JAVA_OPTS")].value
+ pattern: -Xmx1024m
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="JAVA_OPTS")].value
+ pattern: -XX:MaxDirectMemorySize=512m
+
+ - it: holds Store rollouts on OnDelete because readiness is listener-only
+ template: store-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.updateStrategy.type
+ value: OnDelete
+
+ - it: keeps the PD raft rolling restart automatic
+ template: pd-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.updateStrategy.type
+ value: RollingUpdate
diff --git a/helm/hugegraph/tests/credential_contract_test.yaml b/helm/hugegraph/tests/credential_contract_test.yaml
new file mode 100644
index 0000000000..5fbddb830b
--- /dev/null
+++ b/helm/hugegraph/tests/credential_contract_test.yaml
@@ -0,0 +1,133 @@
+#
+# 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.
+#
+
+# The credential contract is printable ASCII, no backslash, no leading or
+# trailing space; the admin password also takes no colon and no space at
+# all. The schema enforces it on inline values (schema aborts
+# cannot be matched by this framework, so those cases live in the CI
+# reject-invalid-values step); the container wrappers enforce it on an
+# existingSecret, which is only seen at Pod start. These cases pin the
+# wrapper checks and the hook contract, so a rewrite cannot silently drop
+# them.
+suite: Credential contract and test hook contract
+tests:
+ - it: checks the admin password byte-wise under the C locale in the Server wrapper
+ template: server-deployment.yaml
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "LC_ALL=C tr -d"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "decodes Basic-auth credentials as ASCII"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "fix the server.auth.admin"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "must not contain a colon"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "\\*' '\\*\\)\\n[^\\n]*# Temporary"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "must not contain spaces"
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "\\[\\[:print:\\]\\]"
+
+ - it: skips the admin password check when authentication is disabled
+ template: server-deployment.yaml
+ set:
+ hubble.enabled: false
+ server.auth.enabled: false
+ asserts:
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "LC_ALL=C tr -d"
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "must not contain spaces"
+
+ - it: checks the PD REST secret byte-wise under the C locale in the Hubble wrapper
+ template: hubble-deployment.yaml
+ set:
+ hubble.enabled: true
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "LC_ALL=C tr -d"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "fix the pd.auth"
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "\\[\\[:print:\\]\\]"
+
+ - it: carries no PD secret check in direct mode, which reads no PD secret
+ template: hubble-deployment.yaml
+ set:
+ hubble.enabled: true
+ hubble.mode: direct
+ asserts:
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "LC_ALL=C tr -d"
+
+ - it: keeps the succeeded test Pod so helm test --logs can read it
+ template: tests/test-connection.yaml
+ asserts:
+ - equal:
+ path: metadata.annotations["helm.sh/hook-delete-policy"]
+ value: before-hook-creation
+
+ - it: sweeps every Pod on every attempt and counts one address family per Pod
+ template: tests/test-connection.yaml
+ asserts:
+ - notMatchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "PASSED"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "grep -v ':'"
+
+ - it: ignores unused credential encoding when Server authentication is disabled
+ set:
+ hubble.enabled: false
+ server.auth.enabled: false
+ server.auth.admin.password: "unused password: value"
+ server.auth.token.value: unused
+ template: server-deployment.yaml
+ asserts:
+ - isKind:
+ of: Deployment
+ - it: existing Secrets take priority over unused inline credentials
+ set:
+ server.auth.admin.existingSecret: provided-admin
+ server.auth.admin.password: "unused password: value"
+ server.auth.token.existingSecret: provided-token
+ server.auth.token.value: unused
+ template: server-deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: provided-admin
+ key: password
diff --git a/helm/hugegraph/tests/easy_modes_test.yaml b/helm/hugegraph/tests/easy_modes_test.yaml
new file mode 100644
index 0000000000..172495d6d5
--- /dev/null
+++ b/helm/hugegraph/tests/easy_modes_test.yaml
@@ -0,0 +1,179 @@
+#
+# 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.
+#
+
+
+suite: easy and strict defaults and explicit overrides
+templates:
+ - server-service.yaml
+ - hubble-service.yaml
+ - pd-service-client.yaml
+ - networkpolicy.yaml
+ - store-statefulset.yaml
+ - server-deployment.yaml
+ - tests/test-connection.yaml
+tests:
+ - it: defaults to NodePort API and UI with internal PD
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ template: server-service.yaml
+ - equal:
+ path: spec.type
+ value: NodePort
+ template: hubble-service.yaml
+ - equal:
+ path: spec.type
+ value: ClusterIP
+ template: pd-service-client.yaml
+ - hasDocuments:
+ count: 0
+ template: networkpolicy.yaml
+ - it: strict defaults are internal and isolated
+ set:
+ mode: strict
+ asserts:
+ - equal:
+ path: spec.type
+ value: ClusterIP
+ template: server-service.yaml
+ - equal:
+ path: spec.type
+ value: ClusterIP
+ template: hubble-service.yaml
+ - hasDocuments:
+ count: 4
+ template: networkpolicy.yaml
+ - it: explicit false and NodePort override strict mode without acknowledgement
+ set:
+ mode: strict
+ networkPolicy.enabled: false
+ server.service.type: NodePort
+ hubble.enabled: false
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ template: server-service.yaml
+ - hasDocuments:
+ count: 0
+ template: networkpolicy.yaml
+ - hasDocuments:
+ count: 0
+ template: hubble-service.yaml
+ - it: supports cluster-specific fixed ports and zero automatic allocation
+ set:
+ server.service.nodePort: 8802
+ hubble.service.nodePort: 8808
+ pd.service.type: NodePort
+ pd.service.restNodePort: 0
+ asserts:
+ - equal:
+ path: spec.ports[0].nodePort
+ value: 8802
+ template: server-service.yaml
+ - equal:
+ path: spec.ports[0].nodePort
+ value: 8808
+ template: hubble-service.yaml
+ - it: reuses existing business images for tool containers by default
+ asserts:
+ - equal:
+ path: spec.template.spec.initContainers[0].image
+ value: hugegraph/store:latest
+ template: store-statefulset.yaml
+ - equal:
+ path: spec.containers[0].image
+ value: hugegraph/server:latest
+ template: tests/test-connection.yaml
+ - equal:
+ path: spec.template.spec.initContainers[0].command[0]
+ value: sh
+ template: store-statefulset.yaml
+ - equal:
+ path: spec.containers[0].command[0]
+ value: sh
+ template: tests/test-connection.yaml
+ - it: mirrors component and inherited tool images without changing digest pins
+ set:
+ global.imageRegistry: registry.internal/project
+ store.image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: registry.internal/project/hugegraph/store@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ template: store-statefulset.yaml
+ - equal:
+ path: spec.template.spec.initContainers[0].image
+ value: registry.internal/project/hugegraph/store@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+ template: store-statefulset.yaml
+ - equal:
+ path: spec.containers[0].image
+ value: registry.internal/project/hugegraph/server:latest
+ template: tests/test-connection.yaml
+ - it: qualified individual images override the mirror
+ set:
+ global.imageRegistry: registry.internal
+ store.image.repository: other.internal/store
+ server.waitImage: tools.internal/http-check:1
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: other.internal/store:latest
+ template: store-statefulset.yaml
+ - equal:
+ path: spec.template.spec.initContainers[0].image
+ value: other.internal/store:latest
+ template: store-statefulset.yaml
+ - equal:
+ path: spec.containers[0].image
+ value: tools.internal/http-check:1
+ template: tests/test-connection.yaml
+ - it: adapts unspecified startup probes to the configured storage wait
+ set:
+ server.storageWaitTimeoutSeconds: 600
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 151
+ template: server-deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: WAIT_STORAGE_TIMEOUT_S
+ value: "600"
+ template: server-deployment.yaml
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: "150"
+ template: server-deployment.yaml
+
+ - it: reuses the component pull policy for offline tool containers
+ set:
+ store.image.pullPolicy: Never
+ server.image.pullPolicy: IfNotPresent
+ asserts:
+ - equal:
+ path: spec.template.spec.initContainers[0].imagePullPolicy
+ value: Never
+ template: store-statefulset.yaml
+ - equal:
+ path: spec.containers[0].imagePullPolicy
+ value: IfNotPresent
+ template: tests/test-connection.yaml
diff --git a/helm/hugegraph/tests/hubble_exposure_test.yaml b/helm/hugegraph/tests/hubble_exposure_test.yaml
new file mode 100644
index 0000000000..d42d5a54c8
--- /dev/null
+++ b/helm/hugegraph/tests/hubble_exposure_test.yaml
@@ -0,0 +1,72 @@
+#
+# 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.
+#
+
+suite: Hubble Service exposure and mode defaults
+templates:
+ - hubble-service.yaml
+tests:
+ - it: defaults to an automatic NodePort in easy mode
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ - notExists:
+ path: spec.ports[0].nodePort
+
+ - it: defaults to ClusterIP in strict mode
+ set:
+ mode: strict
+ asserts:
+ - equal:
+ path: spec.type
+ value: ClusterIP
+
+ - it: honors an explicit NodePort in strict mode without acknowledgement
+ set:
+ mode: strict
+ hubble.service.type: NodePort
+ hubble.service.nodePort: 8808
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ - equal:
+ path: spec.ports[0].nodePort
+ value: 8808
+
+ - it: renders LoadBalancer without acknowledgement
+ set:
+ hubble.service.type: LoadBalancer
+ asserts:
+ - equal:
+ path: spec.type
+ value: LoadBalancer
+
+ - it: honors an explicit ClusterIP in easy mode
+ set:
+ hubble.service.type: ClusterIP
+ asserts:
+ - equal:
+ path: spec.type
+ value: ClusterIP
+
+ - it: treats zero NodePort as automatic allocation
+ set:
+ hubble.service.nodePort: 0
+ asserts:
+ - notExists:
+ path: spec.ports[0].nodePort
diff --git a/helm/hugegraph/tests/identity_guard_fresh_install_test.yaml b/helm/hugegraph/tests/identity_guard_fresh_install_test.yaml
new file mode 100644
index 0000000000..e5ceed7c64
--- /dev/null
+++ b/helm/hugegraph/tests/identity_guard_fresh_install_test.yaml
@@ -0,0 +1,39 @@
+#
+# 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.
+#
+
+# The membership and identity guards read the live StatefulSets through
+# lookup. These cases fake that object, so the refused path (a raft port
+# change, a rename, a replica shrink) and the allowed path (an unchanged
+# upgrade, a fresh install) are both pinned at render time. validateValues
+# is included from the Server Deployment, so the refusals render that.
+# The identity guards read the live StatefulSets; with none present (a
+# fresh install, or any template-only render) every port and name is free.
+suite: Raft identity guards on a fresh install
+templates:
+ - pd-statefulset.yaml
+tests:
+ - it: lets a fresh install choose any raft port and name
+ set:
+ pd.ports.raft: 8611
+ fullnameOverride: fresh
+ asserts:
+ - equal:
+ path: metadata.name
+ value: fresh-pd
+ - equal:
+ path: spec.template.spec.containers[0].ports[2].containerPort
+ value: 8611
diff --git a/helm/hugegraph/tests/identity_guard_selector_test.yaml b/helm/hugegraph/tests/identity_guard_selector_test.yaml
new file mode 100644
index 0000000000..8c11add741
--- /dev/null
+++ b/helm/hugegraph/tests/identity_guard_selector_test.yaml
@@ -0,0 +1,76 @@
+#
+# 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.
+#
+
+# The membership and identity guards read the live StatefulSets through
+# lookup. These cases fake that object, so the refused path (a raft port
+# change, a rename, a replica shrink) and the allowed path (an unchanged
+# upgrade, a fresh install) are both pinned at render time. validateValues
+# is included from the Server Deployment, so the refusals render that.
+# A nameOverride contained in the release name keeps the StatefulSet name
+# (hugegraph.fullname collapses to the release name) but still changes the
+# app.kubernetes.io/name selector label, which Kubernetes forbids on an
+# existing StatefulSet. The fake StatefulSet carries the name that override
+# renders, so only the selector differs.
+suite: nameOverride that keeps the StatefulSet name but changes its selector
+templates:
+ - server-deployment.yaml
+kubernetesProvider:
+ scheme:
+ "apps/v1/StatefulSet":
+ gvr:
+ group: apps
+ version: v1
+ resource: statefulsets
+ namespaced: true
+ objects:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-pd
+ namespace: NAMESPACE
+ labels:
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: pd
+ spec:
+ volumeClaimTemplates:
+ - metadata:
+ name: pd-data
+ replicas: "3"
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: RELEASE
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: pd
+tests:
+ - it: refuses the selector label change with the reason
+ release:
+ upgrade: true
+ set:
+ nameOverride: NAME
+ asserts:
+ - failedTemplate:
+ errorPattern: "changes the app.kubernetes.io/name selector label of the pd StatefulSet RELEASE-NAME-pd from RELEASE to NAME"
+
+ - it: lets the same override through when the selector already matches
+ release:
+ upgrade: true
+ set:
+ nameOverride: RELEASE
+ asserts:
+ - equal:
+ path: metadata.name
+ value: RELEASE-NAME-server
diff --git a/helm/hugegraph/tests/identity_guard_test.yaml b/helm/hugegraph/tests/identity_guard_test.yaml
new file mode 100644
index 0000000000..88e1c41a19
--- /dev/null
+++ b/helm/hugegraph/tests/identity_guard_test.yaml
@@ -0,0 +1,193 @@
+#
+# 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.
+#
+
+# The membership and identity guards read the live StatefulSets through
+# lookup. These cases fake that object, so the refused path (a raft port
+# change, a rename, a replica shrink) and the allowed path (an unchanged
+# upgrade, a fresh install) are both pinned at render time. validateValues
+# is included from the Server Deployment, so the refusals render that.
+suite: Raft identity and membership guards against the live StatefulSet
+templates:
+ - server-deployment.yaml
+ - pd-statefulset.yaml
+ - store-statefulset.yaml
+# The fake objects spell numbers as strings: the fake client cannot deep
+# copy Go ints, and the guards convert through `int` either way.
+kubernetesProvider:
+ scheme:
+ "apps/v1/StatefulSet":
+ gvr:
+ group: apps
+ version: v1
+ resource: statefulsets
+ namespaced: true
+ objects:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-pd
+ namespace: NAMESPACE
+ labels:
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: pd
+ spec:
+ volumeClaimTemplates:
+ - metadata:
+ name: pd-data
+ replicas: "3"
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: pd
+ template:
+ spec:
+ containers:
+ - name: pd
+ ports:
+ - name: grpc
+ containerPort: "8686"
+ - name: rest
+ containerPort: "8620"
+ - name: raft
+ containerPort: "8610"
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-store
+ namespace: NAMESPACE
+ labels:
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: store
+ spec:
+ volumeClaimTemplates:
+ - metadata:
+ name: store-data
+ replicas: "3"
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: store
+ template:
+ spec:
+ containers:
+ - name: store
+ ports:
+ - name: grpc
+ containerPort: "8500"
+ - name: raft
+ containerPort: "8510"
+ - name: rest
+ containerPort: "8520"
+tests:
+ - it: lets an unchanged upgrade through
+ release:
+ upgrade: true
+ template: server-deployment.yaml
+ asserts:
+ - equal:
+ path: spec.replicas
+ value: 3
+
+ - it: refuses a PD raft port change on an initialized release
+ release:
+ upgrade: true
+ template: server-deployment.yaml
+ set:
+ pd.ports.raft: 8611
+ asserts:
+ - failedTemplate:
+ errorPattern: "pd.ports.raft cannot change from 8610 to 8611 on an initialized release"
+
+ - it: refuses a Store raft port change on an initialized release
+ release:
+ upgrade: true
+ template: server-deployment.yaml
+ set:
+ store.ports.raft: 8511
+ asserts:
+ - failedTemplate:
+ errorPattern: "store.ports.raft cannot change from 8510 to 8511 on an initialized release"
+
+ - it: leaves the other ports routinely mutable
+ release:
+ upgrade: true
+ template: pd-statefulset.yaml
+ set:
+ pd.ports.rest: 8621
+ store.ports.grpc: 8501
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].ports[1].containerPort
+ value: 8621
+
+ - it: refuses a rename through fullnameOverride on an initialized release
+ release:
+ upgrade: true
+ template: server-deployment.yaml
+ set:
+ fullnameOverride: renamed
+ asserts:
+ - failedTemplate:
+ errorPattern: "already owns the pd StatefulSet RELEASE-NAME-hugegraph-pd, but the values now name it renamed-pd"
+
+ - it: refuses a rename through nameOverride on an initialized release
+ release:
+ upgrade: true
+ template: server-deployment.yaml
+ set:
+ nameOverride: graph
+ asserts:
+ - failedTemplate:
+ errorPattern: "already owns the (pd|store) StatefulSet RELEASE-NAME-hugegraph-(pd|store), but the values now name it RELEASE-NAME-graph-(pd|store)"
+
+ - it: refuses a Store replica shrink against the live StatefulSet
+ release:
+ upgrade: true
+ template: server-deployment.yaml
+ set:
+ store.replicas: 2
+ store.pdb.minAvailable: 1
+ asserts:
+ - failedTemplate:
+ errorPattern: "store.replicas cannot shrink from 3 to 2 through a helm upgrade"
+
+ - it: refuses PD growth against the live StatefulSet
+ release:
+ upgrade: true
+ template: server-deployment.yaml
+ set:
+ pd.replicas: 5
+ pd.pdb.minAvailable: 3
+ asserts:
+ - failedTemplate:
+ errorPattern: "pd.replicas cannot grow from 3 to 5 through a helm upgrade"
+
+ - it: ignores existing StatefulSet identity and membership on a fresh install
+ template: server-deployment.yaml
+ release:
+ upgrade: false
+ set:
+ fullnameOverride: fresh
+ pd.replicas: 1
+ pd.ports.raft: 8611
+ store.ports.raft: 8511
+ asserts:
+ - equal:
+ path: metadata.name
+ value: fresh-server
diff --git a/helm/hugegraph/tests/image_digest_test.yaml b/helm/hugegraph/tests/image_digest_test.yaml
new file mode 100644
index 0000000000..6f69ef7ddc
--- /dev/null
+++ b/helm/hugegraph/tests/image_digest_test.yaml
@@ -0,0 +1,257 @@
+#
+# 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.
+#
+
+suite: Image reference and digest pinning
+tests:
+ - it: keeps the default pd tag on latest until release images are published
+ template: pd-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/pd:latest
+
+ - it: keeps the default store tag on latest until release images are published
+ template: store-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/store:latest
+
+ - it: keeps the default server tag on latest until release images are published
+ template: server-deployment.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/server:latest
+
+ - it: inherits appVersion for pd when the tag is empty
+ template: pd-statefulset.yaml
+ set:
+ pd.image.tag: ""
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/pd:latest
+
+ - it: inherits appVersion for store when the tag is empty
+ template: store-statefulset.yaml
+ set:
+ store.image.tag: ""
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/store:latest
+
+ - it: overrides appVersion with an explicit store tag
+ template: store-statefulset.yaml
+ set:
+ store.image.tag: pinned-for-test
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/store:pinned-for-test
+
+ - it: inherits appVersion for server when the tag is empty
+ template: server-deployment.yaml
+ set:
+ server.image.tag: ""
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/server:latest
+
+ - it: overrides appVersion with an explicit server tag
+ template: server-deployment.yaml
+ set:
+ server.image.tag: pinned-for-test
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/server:pinned-for-test
+
+ - it: keeps Hubble on its independent tag
+ template: hubble-deployment.yaml
+ set:
+ hubble.enabled: true
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/hubble:latest
+
+ - it: renders repository and tag when no digest is set
+ template: pd-statefulset.yaml
+ set:
+ pd.image.tag: pinned-for-test
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/pd:pinned-for-test
+
+ - it: pins by digest when one is supplied, ignoring the tag
+ template: pd-statefulset.yaml
+ set:
+ pd.image.tag: pinned-for-test
+ pd.image.digest: sha256:43999a5ccda34883a9e4e458e25cb903ce34adbc66782d9f4a5260d68b2b5a82
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: hugegraph/pd@sha256:43999a5ccda34883a9e4e458e25cb903ce34adbc66782d9f4a5260d68b2b5a82
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].image
+ pattern: "helm-dev"
+
+ - it: supports digest pinning on store as well
+ template: store-statefulset.yaml
+ set:
+ store.image.tag: pinned-for-test
+ store.image.digest: sha256:458d77a18542e8a2f7980b075f2fda32026d582200a8983921354398467ff860
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].image
+ pattern: "^hugegraph/store@sha256:458d77a1"
+
+ - it: supports digest pinning on server as well
+ template: server-deployment.yaml
+ set:
+ server.image.tag: pinned-for-test
+ server.image.digest: sha256:30f99c6b9ab605accf96e9c179434b13495547f532dcb89d840231c54f1cc101
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].image
+ pattern: "^hugegraph/server@sha256:30f99c6b"
+
+ - it: applies the global private registry to pd
+ template: pd-statefulset.yaml
+ set:
+ global.imageRegistry: registry.internal:5000/mirror/
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: registry.internal:5000/mirror/hugegraph/pd:latest
+
+ - it: lets a fully qualified pd repository override the global registry
+ template: pd-statefulset.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ pd.image.repository: another.internal/team/pd
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: another.internal/team/pd:latest
+
+ - it: applies the global private registry to store
+ template: store-statefulset.yaml
+ set:
+ global.imageRegistry: registry.internal:5000/mirror/
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: registry.internal:5000/mirror/hugegraph/store:latest
+
+ - it: lets a fully qualified store repository override the global registry
+ template: store-statefulset.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ store.image.repository: another.internal/team/store
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: another.internal/team/store:latest
+
+ - it: applies the global private registry to server
+ template: server-deployment.yaml
+ set:
+ global.imageRegistry: registry.internal:5000/mirror/
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: registry.internal:5000/mirror/hugegraph/server:latest
+
+ - it: lets a fully qualified server repository override the global registry
+ template: server-deployment.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ server.image.repository: another.internal/team/server
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: another.internal/team/server:latest
+
+ - it: applies the global private registry to hubble
+ template: hubble-deployment.yaml
+ set:
+ global.imageRegistry: registry.internal:5000/mirror/
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: registry.internal:5000/mirror/hugegraph/hubble:latest
+
+ - it: lets a fully qualified hubble repository override the global registry
+ template: hubble-deployment.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ hubble.image.repository: another.internal/team/hubble
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].image
+ value: another.internal/team/hubble:latest
+
+ - it: reuses the Store image for PD readiness waiting by default
+ template: store-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.initContainers[0].image
+ value: hugegraph/store:latest
+
+ - it: reuses the private Store image including its digest for readiness waiting
+ template: store-statefulset.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ store.image.digest: sha256:458d77a18542e8a2f7980b075f2fda32026d582200a8983921354398467ff860
+ asserts:
+ - equal:
+ path: spec.template.spec.initContainers[0].image
+ value: registry.internal/mirror/hugegraph/store@sha256:458d77a18542e8a2f7980b075f2fda32026d582200a8983921354398467ff860
+
+ - it: reuses the private Server image for the test hook without a curl image
+ template: tests/test-connection.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ asserts:
+ - equal:
+ path: spec.containers[0].image
+ value: registry.internal/mirror/hugegraph/server:latest
+
+ - it: honors an explicit qualified wait image over the component and global registry
+ template: store-statefulset.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ store.waitImage: tools.internal/readiness:v2
+ asserts:
+ - equal:
+ path: spec.template.spec.initContainers[0].image
+ value: tools.internal/readiness:v2
+
+ - it: applies the global registry to an explicitly chosen unqualified test image
+ template: tests/test-connection.yaml
+ set:
+ global.imageRegistry: registry.internal/mirror
+ server.waitImage: tools/check:v2
+ asserts:
+ - equal:
+ path: spec.containers[0].image
+ value: registry.internal/mirror/tools/check:v2
diff --git a/helm/hugegraph/tests/native_network_options_test.yaml b/helm/hugegraph/tests/native_network_options_test.yaml
new file mode 100644
index 0000000000..157c7d16b3
--- /dev/null
+++ b/helm/hugegraph/tests/native_network_options_test.yaml
@@ -0,0 +1,152 @@
+#
+# 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.
+#
+
+suite: Native Service options and catch-all Ingress
+templates:
+ - server-service.yaml
+ - hubble-service.yaml
+ - pd-service-client.yaml
+ - server-ingress.yaml
+ - hubble-ingress.yaml
+tests:
+ - it: defaults to automatic NodePorts for Server and Hubble
+ template: server-service.yaml
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ - notExists:
+ path: spec.ports[0].nodePort
+ - it: enables the Hubble NodePort by default
+ template: hubble-service.yaml
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ - it: leaves PD reachable only inside the cluster by default
+ template: pd-service-client.yaml
+ asserts:
+ - equal:
+ path: spec.type
+ value: ClusterIP
+ - it: renders a valid custom NodePort below the standard cluster range
+ template: server-service.yaml
+ set:
+ server.service.nodePort: 8802
+ asserts:
+ - equal:
+ path: spec.ports[0].nodePort
+ value: 8802
+ - it: omits zero NodePort for automatic allocation
+ template: hubble-service.yaml
+ set:
+ hubble.service.nodePort: 0
+ asserts:
+ - notExists:
+ path: spec.ports[0].nodePort
+ - it: preserves explicit native load balancer settings including false
+ template: server-service.yaml
+ set:
+ server.service.type: LoadBalancer
+ server.service.externalTrafficPolicy: Local
+ server.service.loadBalancerClass: internal.example/controller
+ server.service.loadBalancerIP: 10.1.2.3
+ server.service.loadBalancerSourceRanges: [10.0.0.0/8]
+ server.service.externalIPs: [10.1.2.4]
+ server.service.allocateLoadBalancerNodePorts: false
+ asserts:
+ - equal:
+ path: spec.externalTrafficPolicy
+ value: Local
+ - equal:
+ path: spec.loadBalancerClass
+ value: internal.example/controller
+ - equal:
+ path: spec.loadBalancerIP
+ value: 10.1.2.3
+ - equal:
+ path: spec.loadBalancerSourceRanges
+ value: [10.0.0.0/8]
+ - equal:
+ path: spec.externalIPs
+ value: [10.1.2.4]
+ - equal:
+ path: spec.allocateLoadBalancerNodePorts
+ value: false
+ - it: applies native load balancer settings to Hubble
+ template: hubble-service.yaml
+ set:
+ hubble.service.type: LoadBalancer
+ hubble.service.externalTrafficPolicy: Local
+ hubble.service.allocateLoadBalancerNodePorts: false
+ asserts:
+ - equal:
+ path: spec.externalTrafficPolicy
+ value: Local
+ - equal:
+ path: spec.allocateLoadBalancerNodePorts
+ value: false
+ - it: applies native load balancer settings to PD
+ template: pd-service-client.yaml
+ set:
+ pd.service.type: LoadBalancer
+ pd.service.externalTrafficPolicy: Local
+ pd.service.allocateLoadBalancerNodePorts: false
+ asserts:
+ - equal:
+ path: spec.externalTrafficPolicy
+ value: Local
+ - equal:
+ path: spec.allocateLoadBalancerNodePorts
+ value: false
+ - it: renders hostless Server ingress using native catch-all semantics
+ template: server-ingress.yaml
+ set:
+ server.ingress.enabled: true
+ server.ingress.hosts:
+ - paths:
+ - path: /
+ pathType: Prefix
+ asserts:
+ - notExists:
+ path: spec.rules[0].host
+ - equal:
+ path: spec.rules[0].http.paths[0].path
+ value: /
+ - it: renders an empty host as a Hubble catch-all ingress
+ template: hubble-ingress.yaml
+ set:
+ hubble.ingress.enabled: true
+ hubble.ingress.hosts:
+ - host: ""
+ paths:
+ - path: /
+ pathType: Prefix
+ asserts:
+ - notExists:
+ path: spec.rules[0].host
+ - it: supplies a catch-all rule when ingress hosts are omitted
+ template: server-ingress.yaml
+ set:
+ server.ingress.enabled: true
+ server.ingress.hosts: []
+ asserts:
+ - notExists:
+ path: spec.rules[0].host
+ - equal:
+ path: spec.rules[0].http.paths[0].backend.service.name
+ value: RELEASE-NAME-hugegraph-server
diff --git a/helm/hugegraph/tests/networkpolicy_test.yaml b/helm/hugegraph/tests/networkpolicy_test.yaml
new file mode 100644
index 0000000000..edc7f6e835
--- /dev/null
+++ b/helm/hugegraph/tests/networkpolicy_test.yaml
@@ -0,0 +1,991 @@
+#
+# 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.
+#
+
+suite: NetworkPolicy
+templates:
+ - networkpolicy.yaml
+tests:
+ - it: enables NetworkPolicy by strict mode defaults
+ set:
+ mode: strict
+ asserts:
+ - hasDocuments:
+ count: 4
+
+ - it: honors explicit false over the strict NetworkPolicy default
+ set:
+ mode: strict
+ networkPolicy.enabled: false
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: renders nothing with the base values
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: renders nothing with the single preset as shipped
+ values:
+ - ../values-single.yaml
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: renders no policy on the ordinary cluster preset
+ values:
+ - ../values-cluster.yaml
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: renders pd, store and server policies on the single preset when enabled
+ values:
+ - ../values-single.yaml
+ set:
+ networkPolicy.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 4
+
+ - it: adds a Hubble policy when Hubble is enabled
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 4
+
+ - it: isolates PD in both directions and admits only its peers and clients
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ asserts:
+ - equal:
+ path: spec.podSelector.matchLabels
+ value:
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: pd
+ - equal:
+ path: spec.policyTypes
+ value: [Ingress, Egress]
+ - lengthEqual:
+ path: spec.ingress
+ count: 2
+ - equal:
+ path: spec.ingress[0]
+ value:
+ from:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: pd
+ ports:
+ - protocol: TCP
+ port: 8610
+ - protocol: TCP
+ port: 8686
+ - lengthEqual:
+ path: spec.ingress[1].from
+ count: 3
+ - equal:
+ path: spec.ingress[1].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: store
+ - equal:
+ path: spec.ingress[1].from[1].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: server
+ - equal:
+ path: spec.ingress[1].from[2].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: hubble
+ - equal:
+ path: spec.ingress[1].ports
+ value:
+ - protocol: TCP
+ port: 8686
+ - protocol: TCP
+ port: 8620
+ - equal:
+ path: spec.egress[0].to[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: pd
+ - equal:
+ path: spec.egress[0].ports
+ value:
+ - protocol: TCP
+ port: 8610
+ - protocol: TCP
+ port: 8686
+ - equal:
+ path: spec.egress[1]
+ value:
+ ports:
+ - protocol: UDP
+ port: 53
+ - protocol: TCP
+ port: 53
+ - lengthEqual:
+ path: spec.egress
+ count: 2
+
+ - it: lets Store peers use raft only and admits Server and Hubble on their ports
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-store
+ asserts:
+ - lengthEqual:
+ path: spec.ingress
+ count: 3
+ - lengthEqual:
+ path: spec.ingress[0].from
+ count: 1
+ - lengthEqual:
+ path: spec.ingress[1].from
+ count: 1
+ - lengthEqual:
+ path: spec.ingress[2].from
+ count: 1
+ - lengthEqual:
+ path: spec.egress[0].to
+ count: 1
+ - lengthEqual:
+ path: spec.egress[1].to
+ count: 1
+ - equal:
+ path: spec.ingress[0].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: store
+ - equal:
+ path: spec.ingress[0].ports
+ value:
+ - protocol: TCP
+ port: 8510
+ - equal:
+ path: spec.ingress[1].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: server
+ - equal:
+ path: spec.ingress[1].ports
+ value:
+ - protocol: TCP
+ port: 8500
+ - protocol: TCP
+ port: 8520
+ - equal:
+ path: spec.ingress[2].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: hubble
+ - equal:
+ path: spec.ingress[2].ports
+ value:
+ - protocol: TCP
+ port: 8520
+ - equal:
+ path: spec.egress[0].to[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: pd
+ - equal:
+ path: spec.egress[0].ports
+ value:
+ - protocol: TCP
+ port: 8686
+ - protocol: TCP
+ port: 8620
+ - equal:
+ path: spec.egress[1].to[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: store
+ - equal:
+ path: spec.egress[1].ports
+ value:
+ - protocol: TCP
+ port: 8510
+ - equal:
+ path: spec.egress[2].ports
+ value:
+ - protocol: UDP
+ port: 53
+ - protocol: TCP
+ port: 53
+
+ - it: admits the test hook and Hubble to the Server and lets it reach PD and Store
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+ asserts:
+ - lengthEqual:
+ path: spec.egress[0].to
+ count: 1
+ - lengthEqual:
+ path: spec.egress[1].to
+ count: 1
+ - lengthEqual:
+ path: spec.egress
+ count: 3
+ - lengthEqual:
+ path: spec.ingress
+ count: 1
+ - lengthEqual:
+ path: spec.ingress[0].from
+ count: 2
+ - equal:
+ path: spec.ingress[0].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: test
+ - equal:
+ path: spec.ingress[0].from[1].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: hubble
+ - equal:
+ path: spec.ingress[0].ports
+ value:
+ - protocol: TCP
+ port: 8080
+ - equal:
+ path: spec.egress[0].ports
+ value:
+ - protocol: TCP
+ port: 8686
+ - protocol: TCP
+ port: 8620
+ - equal:
+ path: spec.egress[1].to[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: store
+ - equal:
+ path: spec.egress[1].ports
+ value:
+ - protocol: TCP
+ port: 8500
+ - protocol: TCP
+ port: 8520
+ - equal:
+ path: spec.egress[2].ports
+ value:
+ - protocol: UDP
+ port: 53
+ - protocol: TCP
+ port: 53
+
+ - it: lets PD-mode Hubble reach Server, PD and Store REST and nothing reach it
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-hubble
+ asserts:
+ - lengthEqual:
+ path: spec.egress
+ count: 4
+ - notExists:
+ path: spec.ingress
+ - contains:
+ path: spec.policyTypes
+ content: Ingress
+ - equal:
+ path: spec.egress[0].to[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: server
+ - equal:
+ path: spec.egress[0].ports
+ value:
+ - protocol: TCP
+ port: 8080
+ - equal:
+ path: spec.egress[1].to[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: pd
+ - equal:
+ path: spec.egress[1].ports
+ value:
+ - protocol: TCP
+ port: 8686
+ - protocol: TCP
+ port: 8620
+ - equal:
+ path: spec.egress[2].to[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: store
+ - equal:
+ path: spec.egress[2].ports
+ value:
+ - protocol: TCP
+ port: 8520
+ - equal:
+ path: spec.egress[3].ports
+ value:
+ - protocol: UDP
+ port: 53
+ - protocol: TCP
+ port: 53
+
+ - it: keeps direct-mode Hubble to the Server only
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ hubble.mode: direct
+ asserts:
+ - lengthEqual:
+ path: spec.egress
+ count: 2
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-hubble
+ - lengthEqual:
+ path: spec.ingress[1].from
+ count: 2
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ - equal:
+ path: spec.ingress[1].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: store
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ - equal:
+ path: spec.ingress[1].from[1].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: server
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ - lengthEqual:
+ path: spec.ingress
+ count: 2
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-store
+
+ - it: leaves every Hubble peer out when Hubble is disabled
+ values:
+ - ../values-cluster.yaml
+ set:
+ hubble.enabled: false
+ networkPolicy.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 3
+ - notContains:
+ path: spec.egress
+ content:
+ to:
+ - podSelector:
+ matchLabels:
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: hubble
+ - notContains:
+ path: spec.ingress[1].from
+ content:
+ podSelector:
+ matchLabels:
+ app.kubernetes.io/name: hugegraph
+ app.kubernetes.io/instance: RELEASE-NAME
+ app.kubernetes.io/component: hubble
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ - lengthEqual:
+ path: spec.ingress
+ count: 2
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-store
+ - lengthEqual:
+ path: spec.ingress[0].from
+ count: 1
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+ - equal:
+ path: spec.ingress[0].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: test
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+
+ - it: follows port overrides in ingress and egress
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ pd.ports.raft: 9610
+ store.ports.rest: 9520
+ server.port: 9080
+ asserts:
+ - equal:
+ path: spec.ingress[0].ports
+ value:
+ - protocol: TCP
+ port: 9610
+ - protocol: TCP
+ port: 8686
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ - equal:
+ path: spec.egress[1].ports
+ value:
+ - protocol: TCP
+ port: 8500
+ - protocol: TCP
+ port: 9520
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+ - equal:
+ path: spec.ingress[0].ports
+ value:
+ - protocol: TCP
+ port: 9080
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+
+ - it: appends the PD extraIngress when PD is exposed and adds nothing open
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ pd.service.type: NodePort
+ pd.service.allowInsecureExposure: true
+ networkPolicy.pd.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ ports:
+ - port: 8620
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ asserts:
+ - lengthEqual:
+ path: spec.ingress
+ count: 3
+ - exists:
+ path: spec.ingress[0].from
+ - exists:
+ path: spec.ingress[1].from
+ - equal:
+ path: spec.ingress[2]
+ value:
+ from:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ ports:
+ - port: 8620
+
+ - it: appends the Server extraIngress for a LoadBalancer Service and adds nothing open
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ server.service.type: LoadBalancer
+ server.service.allowInsecureExposure: true
+ networkPolicy.server.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 192.168.0.0/16
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+ asserts:
+ - lengthEqual:
+ path: spec.ingress
+ count: 2
+ - exists:
+ path: spec.ingress[0].from
+ - equal:
+ path: spec.ingress[1].from[0].ipBlock.cidr
+ value: 192.168.0.0/16
+
+ - it: appends the Server extraIngress for an Ingress and keeps the test peer first
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ server.ingress.enabled: true
+ server.ingress.allowPlainHttp: true
+ networkPolicy.server.extraIngress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: ingress-nginx
+ podSelector:
+ matchLabels:
+ app.kubernetes.io/name: ingress-nginx
+ ports:
+ - port: 8080
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+ asserts:
+ - lengthEqual:
+ path: spec.ingress
+ count: 2
+ - equal:
+ path: spec.ingress[0].from[0].podSelector.matchLabels["app.kubernetes.io/component"]
+ value: test
+ - equal:
+ path: spec.ingress[1]
+ value:
+ from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: ingress-nginx
+ podSelector:
+ matchLabels:
+ app.kubernetes.io/name: ingress-nginx
+ ports:
+ - port: 8080
+
+ - it: appends the Server extraIngress for an advertised URL and adds nothing open
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ server.advertiseUrl: http://hg.example.com:8080
+ networkPolicy.server.extraIngress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: proxy
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+ asserts:
+ - lengthEqual:
+ path: spec.ingress
+ count: 2
+ - exists:
+ path: spec.ingress[1].from
+
+ - it: gives an exposed Hubble exactly the listed ingress rules
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ hubble.service.type: NodePort
+ hubble.service.allowInsecureExposure: true
+ networkPolicy.hubble.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 172.18.0.0/16
+ ports:
+ - port: 8088
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-hubble
+ asserts:
+ - equal:
+ path: spec.ingress
+ value:
+ - from:
+ - ipBlock:
+ cidr: 172.18.0.0/16
+ ports:
+ - port: 8088
+
+ - it: renders no rule without a peer on any document when every exposure is set
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ pd.service.type: LoadBalancer
+ pd.service.allowInsecureExposure: true
+ server.service.type: NodePort
+ server.service.allowInsecureExposure: true
+ server.advertiseUrl: http://hg.example.com:8080
+ hubble.service.type: NodePort
+ hubble.service.allowInsecureExposure: true
+ networkPolicy.pd.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ networkPolicy.server.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ networkPolicy.hubble.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ # advertiseUrl hands the external URL to the in-cluster Hubble too,
+ # so its egress must name that destination.
+ networkPolicy.hubble.extraEgress:
+ - to:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ asserts:
+ - hasDocuments:
+ count: 4
+ - notContains:
+ path: spec.ingress
+ content:
+ ports:
+ - protocol: TCP
+ port: 8686
+ - protocol: TCP
+ port: 8620
+ - notContains:
+ path: spec.ingress
+ content:
+ ports:
+ - protocol: TCP
+ port: 8080
+ - notContains:
+ path: spec.ingress
+ content:
+ ports:
+ - protocol: TCP
+ port: 8088
+
+ - it: selects a component on every document and renders no default-deny object
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 4
+ - exists:
+ path: spec.podSelector.matchLabels["app.kubernetes.io/component"]
+ - exists:
+ path: spec.podSelector.matchLabels["app.kubernetes.io/instance"]
+
+ - it: follows nameOverride in every selector and fullnameOverride in names
+ values:
+ - ../values-cluster.yaml
+ set:
+ hubble.enabled: false
+ networkPolicy.enabled: true
+ nameOverride: graphdb
+ fullnameOverride: x
+ asserts:
+ - equal:
+ path: metadata.name
+ value: x-pd
+ documentIndex: 0
+ - equal:
+ path: metadata.name
+ value: x-store
+ documentIndex: 1
+ - equal:
+ path: metadata.name
+ value: x-server
+ documentIndex: 2
+ - equal:
+ path: spec.podSelector.matchLabels["app.kubernetes.io/name"]
+ value: graphdb
+ - equal:
+ path: spec.ingress[0].from[0].podSelector.matchLabels["app.kubernetes.io/name"]
+ value: graphdb
+ - equal:
+ path: spec.egress[0].to[0].podSelector.matchLabels["app.kubernetes.io/name"]
+ value: graphdb
+
+ - it: keeps names within 63 characters and the full release name in selectors
+ release:
+ name: abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabc
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ asserts:
+ - matchRegex:
+ path: metadata.name
+ pattern: ^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$
+ - equal:
+ path: spec.podSelector.matchLabels["app.kubernetes.io/instance"]
+ value: abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabc
+ - equal:
+ path: spec.egress[0].to[0].podSelector.matchLabels["app.kubernetes.io/instance"]
+ value: abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabc
+
+ - it: keeps two extraIngress rules in order after the chart rules
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ networkPolicy.pd.extraIngress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: monitoring
+ ports:
+ - port: 8620
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: vermeer
+ ports:
+ - port: 8686
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-pd
+ asserts:
+ - lengthEqual:
+ path: spec.ingress
+ count: 4
+ - equal:
+ path: spec.ingress[2].from[0].namespaceSelector.matchLabels["kubernetes.io/metadata.name"]
+ value: monitoring
+ - equal:
+ path: spec.ingress[3].from[0].namespaceSelector.matchLabels["kubernetes.io/metadata.name"]
+ value: vermeer
+
+ - it: ignores Hubble extra rules while Hubble is disabled
+ values:
+ - ../values-cluster.yaml
+ set:
+ hubble.enabled: false
+ networkPolicy.enabled: true
+ networkPolicy.hubble.extraIngress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: ops
+ networkPolicy.hubble.extraEgress:
+ - to:
+ - ipBlock:
+ cidr: 10.0.0.10/32
+ asserts:
+ - hasDocuments:
+ count: 3
+ - notContains:
+ path: spec.ingress
+ content:
+ from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: ops
+
+ - it: treats a whitespace-only advertiseUrl as unset
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ server.advertiseUrl: " "
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server
+ asserts:
+ - lengthEqual:
+ path: spec.ingress
+ count: 1
+
+ - it: renders a component whose networkPolicy key was removed
+ values:
+ - ../values-cluster.yaml
+ set:
+ hubble.enabled: false
+ networkPolicy.enabled: true
+ networkPolicy.pd: null
+ asserts:
+ - hasDocuments:
+ count: 3
+
+ - it: matches the reviewed cluster plus Hubble render
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 4
+ - matchSnapshot: {}
+ # helm-unittest v1.0.0 skips full comparison for matchSnapshot: {}.
+ # Guard release metadata explicitly until that plugin issue is resolved.
+ - equal:
+ path: metadata.labels["app.kubernetes.io/version"]
+ value: "latest"
+ documentIndex: 0
+ - equal:
+ path: metadata.labels["helm.sh/chart"]
+ value: hugegraph-helm-0.2.0
+ documentIndex: 0
+ - equal:
+ path: metadata.labels["app.kubernetes.io/version"]
+ value: "latest"
+ documentIndex: 1
+ - equal:
+ path: metadata.labels["helm.sh/chart"]
+ value: hugegraph-helm-0.2.0
+ documentIndex: 1
+ - equal:
+ path: metadata.labels["app.kubernetes.io/version"]
+ value: "latest"
+ documentIndex: 2
+ - equal:
+ path: metadata.labels["helm.sh/chart"]
+ value: hugegraph-helm-0.2.0
+ documentIndex: 2
+ - equal:
+ path: metadata.labels["app.kubernetes.io/version"]
+ value: "latest"
+ documentIndex: 3
+ - equal:
+ path: metadata.labels["helm.sh/chart"]
+ value: hugegraph-helm-0.2.0
+ documentIndex: 3
+
+ - it: appends extra rules verbatim
+ values:
+ - ../values-cluster.yaml
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ networkPolicy.store.extraIngress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: monitoring
+ ports:
+ - port: 8520
+ networkPolicy.hubble.extraEgress:
+ - to:
+ - ipBlock:
+ cidr: 10.0.0.10/32
+ ports:
+ - port: 9200
+ asserts:
+ - contains:
+ path: spec.ingress
+ content:
+ from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: monitoring
+ ports:
+ - port: 8520
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-store
+ - contains:
+ path: spec.egress
+ content:
+ to:
+ - ipBlock:
+ cidr: 10.0.0.10/32
+ ports:
+ - port: 9200
+ documentSelector:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-hubble
+
+ - it: rejects a non-boolean enabled flag through the schema
+ set:
+ networkPolicy.enabled: "yes"
+ asserts:
+ - failedTemplate:
+ errorPattern: "networkPolicy"
+
+ - it: rejects egress rules on components that take none
+ set:
+ networkPolicy.store.extraEgress:
+ - to:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ asserts:
+ - failedTemplate:
+ errorPattern: "(additional properties 'extraEgress' not allowed|Additional property extraEgress is not allowed)"
+
+ - it: rejects a non-list extraIngress
+ set:
+ networkPolicy.pd.extraIngress: "abc"
+ asserts:
+ - failedTemplate:
+ errorPattern: "extraIngress"
+
+ - it: rejects a null extraIngress rule
+ set:
+ networkPolicy.pd.extraIngress:
+ - null
+ asserts:
+ - failedTemplate:
+ errorPattern: "extraIngress"
+
+ - it: rejects an unknown networkPolicy key
+ set:
+ networkPolicy.unknown: 1
+ asserts:
+ - failedTemplate:
+ errorPattern: "(additional properties 'unknown' not allowed|Additional property unknown is not allowed)"
+
+ - it: rejects the removed ingressControllers key
+ set:
+ networkPolicy.ingressControllers: []
+ asserts:
+ - failedTemplate:
+ errorPattern: "(additional properties 'ingressControllers' not allowed|Additional property ingressControllers is not allowed)"
+
+ - it: rejects a string enabled flag
+ set:
+ networkPolicy.enabled: "true"
+ asserts:
+ - failedTemplate:
+ errorPattern: "networkPolicy[./]enabled"
+
+ - it: accepts Kubernetes-valid an extraIngress rule without from
+ set:
+ networkPolicy.server.extraIngress:
+ - ports:
+ - port: 8080
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts Kubernetes-valid an extraIngress rule with an empty from
+ set:
+ networkPolicy.server.extraIngress:
+ - from: []
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts Kubernetes-valid an empty extraIngress rule
+ set:
+ networkPolicy.pd.extraIngress:
+ - {}
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts Kubernetes-valid an empty peer
+ set:
+ networkPolicy.pd.extraIngress:
+ - from:
+ - {}
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts Kubernetes-valid a Hubble egress rule without to
+ set:
+ networkPolicy.hubble.extraEgress:
+ - ports:
+ - port: 9200
+ asserts:
+ - notFailedTemplate: {}
diff --git a/helm/hugegraph/tests/notes_test.yaml b/helm/hugegraph/tests/notes_test.yaml
new file mode 100644
index 0000000000..a610fa7fa3
--- /dev/null
+++ b/helm/hugegraph/tests/notes_test.yaml
@@ -0,0 +1,41 @@
+#
+# 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.
+#
+
+# The install notes warn about chart-generated credentials under a
+# template-only renderer. The rollout checksums hash the generated values,
+# so such a renderer rolls the Pods on every sync; the warning must say so.
+suite: Install notes
+templates:
+ - NOTES.txt
+tests:
+ - it: directs template-only pipelines to deterministic credential sources
+ asserts:
+ - matchRegexRaw:
+ pattern: "Chart-generated credentials are in use: pd.auth, server.auth.admin, server.auth.token"
+ - matchRegexRaw:
+ pattern: "For GitOps/template-only pipelines use fixed credentials or existingSecret"
+ - notMatchRegexRaw:
+ pattern: "without rolling"
+
+ - it: omits the warning when every credential comes from an existing Secret
+ set:
+ pd.auth.existingSecret: byo-pd
+ server.auth.admin.existingSecret: byo-admin
+ server.auth.token.existingSecret: byo-token
+ asserts:
+ - notMatchRegexRaw:
+ pattern: "Chart-generated credentials"
diff --git a/helm/hugegraph/tests/pd_auth_secret_test.yaml b/helm/hugegraph/tests/pd_auth_secret_test.yaml
new file mode 100644
index 0000000000..62aed6ff31
--- /dev/null
+++ b/helm/hugegraph/tests/pd_auth_secret_test.yaml
@@ -0,0 +1,156 @@
+#
+# 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.
+#
+suite: PD REST auth Secret wiring
+tests:
+ - it: creates a kept release-pd-auth Secret by default
+ template: pd-auth-secret.yaml
+ asserts:
+ - hasDocuments:
+ count: 1
+ - equal:
+ path: metadata.name
+ value: RELEASE-NAME-pd-auth
+ - equal:
+ path: metadata.annotations["helm.sh/resource-policy"]
+ value: keep
+ - isNotEmpty:
+ path: data["secret-key"]
+
+ - it: renders no Secret when an operator-supplied Secret is named
+ template: pd-auth-secret.yaml
+ set:
+ pd.auth.existingSecret: my-pd-secret
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: hands PD the secret from the Secret, never a literal value
+ template: pd-statefulset.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_PD_AUTH_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-pd-auth
+ key: secret-key
+ - isNotEmpty:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+
+ - it: gives the Server storage wait the same secret as PD_AUTH_PASSWORD
+ template: server-deployment.yaml
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: PD_AUTH_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-pd-auth
+ key: secret-key
+ - isNotEmpty:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+
+ - it: gives Hubble the secret and writes it into the properties file
+ template: hubble-deployment.yaml
+ set:
+ hubble.enabled: true
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_HUBBLE_PD_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: RELEASE-NAME-pd-auth
+ key: secret-key
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "operations\\.pd\\.username=hubble"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "operations\\.pd\\.password=%s"
+ # The wrapper refuses secret values the Java properties parser would
+ # reinterpret; inline values are schema-checked, an existingSecret is
+ # only seen here.
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "printable ASCII"
+ - matchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "HG_HUBBLE_PD_PASSWORD:\\?"
+ - isNotEmpty:
+ path: spec.template.metadata.annotations["checksum/pd-auth"]
+
+ - it: skips the PD secret guard in direct mode, which mounts no PD secret
+ template: hubble-deployment.yaml
+ set:
+ hubble.enabled: true
+ hubble.mode: direct
+ asserts:
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].args[0]
+ pattern: "HG_HUBBLE_PD_PASSWORD"
+
+ - it: points PD at the operator-supplied Secret and key
+ template: pd-statefulset.yaml
+ set:
+ pd.auth.existingSecret: my-pd-secret
+ pd.auth.key: pd-pass
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_PD_AUTH_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: my-pd-secret
+ key: pd-pass
+
+ - it: points the Server storage wait at the operator-supplied Secret and key
+ template: server-deployment.yaml
+ set:
+ pd.auth.existingSecret: my-pd-secret
+ pd.auth.key: pd-pass
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: PD_AUTH_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: my-pd-secret
+ key: pd-pass
+
+ - it: rejects operator overrides of the chart-managed PD_AUTH_PASSWORD
+ template: server-deployment.yaml
+ set:
+ server.extraEnv:
+ - name: PD_AUTH_PASSWORD
+ value: x
+ asserts:
+ - failedTemplate:
+ errorPattern: "server.extraEnv must not set the chart-managed variable PD_AUTH_PASSWORD"
+
+ - it: keeps a YAML-coercible Secret key literal
+ template: server-secret.yaml
+ set:
+ server.auth.admin.key: "on"
+ asserts:
+ - exists:
+ path: data.on
diff --git a/helm/hugegraph/tests/pd_javaopts_test.yaml b/helm/hugegraph/tests/pd_javaopts_test.yaml
new file mode 100644
index 0000000000..fa0d506fc4
--- /dev/null
+++ b/helm/hugegraph/tests/pd_javaopts_test.yaml
@@ -0,0 +1,66 @@
+#
+# 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.
+#
+
+suite: PD JAVA_OPTS derivation
+templates:
+ - pd-statefulset.yaml
+tests:
+ - it: disables the raft IP whitelist in-cluster by default
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: JAVA_OPTS
+ value: "-Dpartition.default-shard-count=3 -Draft.ip-whitelist.enabled=false -Draft.rpc-timeout=3000"
+
+ - it: re-enables the whitelist when the operator opts in
+ set:
+ pd.raftIpWhitelistEnabled: true
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="JAVA_OPTS")].value
+ pattern: "-Draft\\.ip-whitelist\\.enabled=true"
+
+ - it: derives shard count 1 when store replicas are below 3
+ set:
+ store.replicas: 1
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="JAVA_OPTS")].value
+ pattern: "-Dpartition\\.default-shard-count=1"
+
+ - it: appends operator javaOpts after the derived flags
+ set:
+ pd.javaOpts: "-Xmx2g"
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="JAVA_OPTS")].value
+ pattern: "-Draft\\.rpc-timeout=3000 -Xmx2g$"
+ - it: renders a custom raft RPC timeout
+ set:
+ pd.raftRpcTimeoutMs: 5000
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="JAVA_OPTS")].value
+ pattern: "-Draft\\.rpc-timeout=5000"
+ - it: omits the raft RPC timeout when set empty, preserving the image default
+ set:
+ pd.raftRpcTimeoutMs: ""
+ asserts:
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="JAVA_OPTS")].value
+ pattern: "rpc-timeout"
diff --git a/helm/hugegraph/tests/pd_readiness_path_test.yaml b/helm/hugegraph/tests/pd_readiness_path_test.yaml
new file mode 100644
index 0000000000..ab25522109
--- /dev/null
+++ b/helm/hugegraph/tests/pd_readiness_path_test.yaml
@@ -0,0 +1,117 @@
+#
+# 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.
+#
+
+suite: PD readiness path and Store wait path
+tests:
+ - it: reads PD readiness from /v1/ready by default, startup and liveness from /v1/health
+ template: pd-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].readinessProbe.httpGet.path
+ value: /v1/ready
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.httpGet.path
+ value: /v1/health
+ - equal:
+ path: spec.template.spec.containers[0].livenessProbe.httpGet.path
+ value: /v1/health
+
+ - it: moves only the PD readiness probe when pd.readinessPath is set back for an older image
+ template: pd-statefulset.yaml
+ set:
+ pd.readinessPath: /v1/health
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].readinessProbe.httpGet.path
+ value: /v1/health
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.httpGet.path
+ value: /v1/health
+ - equal:
+ path: spec.template.spec.containers[0].livenessProbe.httpGet.path
+ value: /v1/health
+
+ - it: polls /v1/ready on each PD peer in the Store wait by default
+ template: store-statefulset.yaml
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.initContainers[0].command[2]
+ pattern: 'WAIT_PATH="/v1/ready"'
+ - matchRegex:
+ path: spec.template.spec.initContainers[0].command[2]
+ pattern: 'curl -fsS --connect-timeout 2 --max-time 5 "http://\${peer}\${WAIT_PATH}"'
+
+ - it: polls store.waitPath on each PD peer when set back for an older image
+ template: store-statefulset.yaml
+ set:
+ store.waitPath: /v1/health
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.initContainers[0].command[2]
+ pattern: 'WAIT_PATH="/v1/health"'
+ - notMatchRegex:
+ path: spec.template.spec.initContainers[0].command[2]
+ pattern: 'v1/ready'
+
+ - it: leaves the Store's own probes on /v1/health whatever store.waitPath is
+ template: store-statefulset.yaml
+ set:
+ store.waitPath: /v1/ready
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].readinessProbe.httpGet.path
+ value: /v1/health
+
+ - it: keeps PD startup and liveness off the raft-aware path with several PDs
+ template: pd-statefulset.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.httpGet.path
+ value: /v1/health
+ - equal:
+ path: spec.template.spec.containers[0].livenessProbe.httpGet.path
+ value: /v1/health
+
+ # A single PD has no election to lose, so a permanent step-down has to be
+ # restartable; older images pass /v1/health forever. Startup follows liveness so the
+ # boot-to-ready window is charged to the startup budget, not the liveness one.
+ - it: moves PD startup and liveness to the raft-aware path at one replica
+ template: pd-statefulset.yaml
+ set:
+ pd.replicas: 1
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.httpGet.path
+ value: /v1/ready
+ - equal:
+ path: spec.template.spec.containers[0].livenessProbe.httpGet.path
+ value: /v1/ready
+ - equal:
+ path: spec.template.spec.containers[0].readinessProbe.httpGet.path
+ value: /v1/ready
+
+ - it: lets pd.livenessPath override the derived choice in both probes
+ template: pd-statefulset.yaml
+ set:
+ pd.livenessPath: /v1/ready
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.httpGet.path
+ value: /v1/ready
+ - equal:
+ path: spec.template.spec.containers[0].livenessProbe.httpGet.path
+ value: /v1/ready
diff --git a/helm/hugegraph/tests/persistence_hubble_test.yaml b/helm/hugegraph/tests/persistence_hubble_test.yaml
new file mode 100644
index 0000000000..63f3850e42
--- /dev/null
+++ b/helm/hugegraph/tests/persistence_hubble_test.yaml
@@ -0,0 +1,111 @@
+#
+# 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.
+#
+
+suite: Hubble persistence inheritance and temporary data mount
+tests:
+ - it: creates the seventh default persistent volume after three PD and three Store claims
+ template: hubble-pvc.yaml
+ asserts:
+ - hasDocuments:
+ count: 1
+ - isKind:
+ of: PersistentVolumeClaim
+
+ - it: defaults to the persistent data mount with Recreate rollout
+ template: hubble-deployment.yaml
+ asserts:
+ - equal:
+ path: spec.replicas
+ value: 1
+ - equal:
+ path: spec.strategy.type
+ value: Recreate
+ - exists:
+ path: spec.template.spec.volumes[0].persistentVolumeClaim
+ - equal:
+ path: spec.template.spec.containers[0].volumeMounts[0].mountPath
+ value: /hubble-data
+
+ - it: global temporary storage omits the Hubble PVC
+ template: hubble-pvc.yaml
+ set:
+ persistence.enabled: false
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: temporary storage uses the same datasource path on emptyDir
+ template: hubble-deployment.yaml
+ set:
+ persistence.enabled: false
+ asserts:
+ - equal:
+ path: spec.template.spec.volumes[0].emptyDir
+ value: {}
+ - notExists:
+ path: spec.template.spec.volumes[0].persistentVolumeClaim
+ - equal:
+ path: spec.template.spec.containers[0].volumeMounts[0].mountPath
+ value: /hubble-data
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: SPRING_DATASOURCE_URL
+ value: jdbc:h2:file:/hubble-data/db;DB_CLOSE_ON_EXIT=FALSE
+
+ - it: explicit Hubble persistence wins over global temporary storage
+ template: hubble-pvc.yaml
+ set:
+ persistence.enabled: false
+ hubble.persistence.enabled: true
+ asserts:
+ - hasDocuments:
+ count: 1
+
+ - it: explicit Hubble temporary storage wins over global persistence
+ template: hubble-pvc.yaml
+ set:
+ persistence.enabled: true
+ hubble.persistence.enabled: false
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: disabled UI never creates a PVC
+ template: hubble-pvc.yaml
+ set:
+ hubble.enabled: false
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: empty storageClass inherits the Kubernetes default
+ template: hubble-pvc.yaml
+ set:
+ hubble.persistence.storageClassName: ""
+ asserts:
+ - notExists:
+ path: spec.storageClassName
+
+ - it: dash storageClass explicitly disables the Kubernetes default
+ template: hubble-pvc.yaml
+ set:
+ hubble.persistence.storageClassName: "-"
+ asserts:
+ - equal:
+ path: spec.storageClassName
+ value: ""
diff --git a/helm/hugegraph/tests/persistence_statefulset_test.yaml b/helm/hugegraph/tests/persistence_statefulset_test.yaml
new file mode 100644
index 0000000000..850faeaa30
--- /dev/null
+++ b/helm/hugegraph/tests/persistence_statefulset_test.yaml
@@ -0,0 +1,116 @@
+#
+# 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.
+#
+
+suite: PD and Store persistence inheritance
+templates:
+ - pd-statefulset.yaml
+ - store-statefulset.yaml
+tests:
+ - it: defaults to three persistent replicas per stateful component
+ asserts:
+ - equal:
+ path: spec.replicas
+ value: 3
+ - lengthEqual:
+ path: spec.volumeClaimTemplates
+ count: 1
+ - notExists:
+ path: spec.template.spec.volumes
+
+ - it: global temporary storage mounts emptyDir without PVC retention settings
+ set:
+ persistence.enabled: false
+ asserts:
+ - notExists:
+ path: spec.volumeClaimTemplates
+ - notExists:
+ path: spec.persistentVolumeClaimRetentionPolicy
+ - equal:
+ path: spec.template.spec.volumes[0].emptyDir
+ value: {}
+
+ - it: explicit component persistence wins over global temporary storage
+ set:
+ persistence.enabled: false
+ pd.storage.enabled: true
+ store.storage.enabled: true
+ asserts:
+ - lengthEqual:
+ path: spec.volumeClaimTemplates
+ count: 1
+ - notExists:
+ path: spec.template.spec.volumes
+
+ - it: explicit component temporary storage wins over global persistence
+ set:
+ persistence.enabled: true
+ pd.storage.enabled: false
+ store.storage.enabled: false
+ asserts:
+ - notExists:
+ path: spec.volumeClaimTemplates
+ - equal:
+ path: spec.template.spec.volumes[0].emptyDir
+ value: {}
+
+ - it: empty storageClass inherits the Kubernetes default
+ set:
+ pd.storage.storageClassName: ""
+ store.storage.storageClassName: ""
+ asserts:
+ - notExists:
+ path: spec.volumeClaimTemplates[0].spec.storageClassName
+
+ - it: dash storageClass explicitly disables the Kubernetes default
+ set:
+ pd.storage.storageClassName: "-"
+ store.storage.storageClassName: "-"
+ asserts:
+ - equal:
+ path: spec.volumeClaimTemplates[0].spec.storageClassName
+ value: ""
+
+ - it: named storageClass is preserved
+ set:
+ pd.storage.storageClassName: internal-local
+ store.storage.storageClassName: internal-local
+ asserts:
+ - equal:
+ path: spec.volumeClaimTemplates[0].spec.storageClassName
+ value: internal-local
+
+ - it: PD override leaves Store persistence inherited independently
+ template: store-statefulset.yaml
+ set:
+ persistence.enabled: true
+ pd.storage.enabled: false
+ asserts:
+ - lengthEqual:
+ path: spec.volumeClaimTemplates
+ count: 1
+
+ - it: Store override leaves PD persistence inherited independently
+ template: pd-statefulset.yaml
+ set:
+ persistence.enabled: false
+ store.storage.enabled: true
+ asserts:
+ - equal:
+ path: spec.template.spec.volumes[0].emptyDir
+ value: {}
+ - notExists:
+ path: spec.volumeClaimTemplates
diff --git a/helm/hugegraph/tests/persistence_upgrade_legacy_hubble_test.yaml b/helm/hugegraph/tests/persistence_upgrade_legacy_hubble_test.yaml
new file mode 100644
index 0000000000..16a3c4182d
--- /dev/null
+++ b/helm/hugegraph/tests/persistence_upgrade_legacy_hubble_test.yaml
@@ -0,0 +1,100 @@
+#
+# 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.
+#
+
+suite: Legacy Hubble storage upgrade guard
+templates:
+ - server-deployment.yaml
+kubernetesProvider:
+ scheme:
+ "apps/v1/StatefulSet":
+ gvr:
+ group: apps
+ version: v1
+ resource: statefulsets
+ namespaced: true
+ "apps/v1/Deployment":
+ gvr:
+ group: apps
+ version: v1
+ resource: deployments
+ namespaced: true
+ objects:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-pd
+ namespace: NAMESPACE
+ spec:
+ replicas: "3"
+ volumeClaimTemplates:
+ - metadata:
+ name: pd-data
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-store
+ namespace: NAMESPACE
+ spec:
+ replicas: "3"
+ volumeClaimTemplates:
+ - metadata:
+ name: store-data
+ - apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: RELEASE-NAME-hugegraph-hubble
+ namespace: NAMESPACE
+ spec:
+ template:
+ spec:
+ containers:
+ - name: hubble
+tests:
+ - it: explains how to preserve the volume-less legacy storage mode
+ release:
+ upgrade: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "legacy Hubble has no data volume.*hubble.persistence.enabled=false.*--reuse-values"
+
+ - it: accepts an explicit legacy temporary override despite global persistence
+ release:
+ upgrade: true
+ set:
+ hubble.persistence.enabled: false
+ asserts:
+ - isKind:
+ of: Deployment
+
+ - it: checks an explicit persistent override even when the global flag is false
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: false
+ pd.storage.enabled: true
+ store.storage.enabled: true
+ hubble.persistence.enabled: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "legacy Hubble has no data volume"
+
+ - it: does not apply the legacy live guard to fresh installations
+ release:
+ upgrade: false
+ asserts:
+ - isKind:
+ of: Deployment
diff --git a/helm/hugegraph/tests/persistence_upgrade_persistent_test.yaml b/helm/hugegraph/tests/persistence_upgrade_persistent_test.yaml
new file mode 100644
index 0000000000..378ae5705c
--- /dev/null
+++ b/helm/hugegraph/tests/persistence_upgrade_persistent_test.yaml
@@ -0,0 +1,115 @@
+#
+# 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.
+#
+
+suite: Persistence upgrade guards from persistent storage
+templates:
+ - server-deployment.yaml
+kubernetesProvider:
+ scheme:
+ "apps/v1/StatefulSet":
+ gvr:
+ group: apps
+ version: v1
+ resource: statefulsets
+ namespaced: true
+ "apps/v1/Deployment":
+ gvr:
+ group: apps
+ version: v1
+ resource: deployments
+ namespaced: true
+ objects:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-pd
+ namespace: NAMESPACE
+ spec:
+ replicas: "3"
+ volumeClaimTemplates:
+ - metadata:
+ name: pd-data
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-store
+ namespace: NAMESPACE
+ spec:
+ replicas: "3"
+ volumeClaimTemplates:
+ - metadata:
+ name: store-data
+ - apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: RELEASE-NAME-hugegraph-hubble
+ namespace: NAMESPACE
+ spec:
+ template:
+ spec:
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: RELEASE-NAME-hugegraph-hubble-data
+tests:
+ - it: rejects changing pd away from persistent storage on upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: true
+ pd.storage.enabled: false
+ asserts:
+ - failedTemplate:
+ errorPattern: "pd.storage.enabled cannot change on an initialized release"
+
+ - it: rejects changing store away from persistent storage on upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: true
+ store.storage.enabled: false
+ asserts:
+ - failedTemplate:
+ errorPattern: "store.storage.enabled cannot change on an initialized release"
+
+ - it: rejects changing hubble away from persistent storage on upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: true
+ hubble.persistence.enabled: false
+ asserts:
+ - failedTemplate:
+ errorPattern: "hubble.persistence.enabled cannot change on an initialized release"
+
+ - it: accepts an unchanged persistent storage upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: true
+ asserts:
+ - isKind:
+ of: Deployment
+
+ - it: fresh installation skips live storage migration checks
+ release:
+ upgrade: false
+ set:
+ persistence.enabled: false
+ asserts:
+ - isKind:
+ of: Deployment
diff --git a/helm/hugegraph/tests/persistence_upgrade_temporary_test.yaml b/helm/hugegraph/tests/persistence_upgrade_temporary_test.yaml
new file mode 100644
index 0000000000..a1242a25a5
--- /dev/null
+++ b/helm/hugegraph/tests/persistence_upgrade_temporary_test.yaml
@@ -0,0 +1,108 @@
+#
+# 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.
+#
+
+suite: Persistence upgrade guards from temporary storage
+templates:
+ - server-deployment.yaml
+kubernetesProvider:
+ scheme:
+ "apps/v1/StatefulSet":
+ gvr:
+ group: apps
+ version: v1
+ resource: statefulsets
+ namespaced: true
+ "apps/v1/Deployment":
+ gvr:
+ group: apps
+ version: v1
+ resource: deployments
+ namespaced: true
+ objects:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-pd
+ namespace: NAMESPACE
+ spec:
+ replicas: "3"
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ metadata:
+ name: RELEASE-NAME-hugegraph-store
+ namespace: NAMESPACE
+ spec:
+ replicas: "3"
+ - apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: RELEASE-NAME-hugegraph-hubble
+ namespace: NAMESPACE
+ spec:
+ template:
+ spec:
+ volumes:
+ - name: data
+ emptyDir: {}
+tests:
+ - it: rejects changing pd away from temporary storage on upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: false
+ pd.storage.enabled: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "pd.storage.enabled cannot change on an initialized release"
+
+ - it: rejects changing store away from temporary storage on upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: false
+ store.storage.enabled: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "store.storage.enabled cannot change on an initialized release"
+
+ - it: rejects changing hubble away from temporary storage on upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: false
+ hubble.persistence.enabled: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "hubble.persistence.enabled cannot change on an initialized release"
+
+ - it: accepts an unchanged temporary storage upgrade
+ release:
+ upgrade: true
+ set:
+ persistence.enabled: false
+ asserts:
+ - isKind:
+ of: Deployment
+
+ - it: fresh installation skips live storage migration checks
+ release:
+ upgrade: false
+ set:
+ persistence.enabled: true
+ asserts:
+ - isKind:
+ of: Deployment
diff --git a/helm/hugegraph/tests/practical_notes_test.yaml b/helm/hugegraph/tests/practical_notes_test.yaml
new file mode 100644
index 0000000000..5ed67fd888
--- /dev/null
+++ b/helm/hugegraph/tests/practical_notes_test.yaml
@@ -0,0 +1,133 @@
+#
+# 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.
+#
+
+suite: Practical access notes
+templates:
+ - NOTES.txt
+tests:
+ - it: watches one resource type per command and links current operations docs
+ asserts:
+ - matchRegexRaw:
+ pattern: "kubectl get pods -n NAMESPACE -l app.kubernetes.io/instance=RELEASE-NAME -w"
+ - matchRegexRaw:
+ pattern: "kubectl get pvc -n NAMESPACE -w"
+ - notMatchRegexRaw:
+ pattern: "kubectl get pods,pvc.*-w"
+ - notMatchRegexRaw:
+ pattern: "see README"
+ - matchRegexRaw:
+ pattern: "helm/hugegraph/docs/operations.md#credentials"
+ - it: reports automatic NodePort discovery and seven claims
+ asserts:
+ - matchRegexRaw:
+ pattern: "needs 7 persistent volume claim"
+ - matchRegexRaw:
+ pattern: "kubectl get nodes -o wide"
+ - matchRegexRaw:
+ pattern: "http://:"
+ - notMatchRegexRaw:
+ pattern: "never expose"
+ - it: explains ClusterIP for strict mode
+ set:
+ mode: strict
+ asserts:
+ - matchRegexRaw:
+ pattern: "ClusterIP is reachable inside the cluster"
+ - it: shows LoadBalancer status discovery
+ set:
+ server.service.type: LoadBalancer
+ asserts:
+ - matchRegexRaw:
+ pattern: "status.loadBalancer.ingress"
+ - it: reports zero Hubble persistence after disabling the UI
+ set:
+ hubble.enabled: false
+ asserts:
+ - matchRegexRaw:
+ pattern: "needs 6 persistent volume claim"
+ - notMatchRegexRaw:
+ pattern: "Hubble 1 replica"
+ - it: uses HTTP for an ingress without TLS
+ set:
+ server.ingress.enabled: true
+ server.ingress.hosts: [{host: internal.example, paths: [{path: /, pathType: Prefix}]}]
+ asserts:
+ - matchRegexRaw:
+ pattern: "http://internal.example/"
+ - notMatchRegexRaw:
+ pattern: "https://internal.example/"
+ - it: uses HTTPS for the configured TLS host
+ set:
+ server.ingress.enabled: true
+ server.ingress.hosts: [{host: internal.example, paths: [{path: /, pathType: Prefix}]}]
+ server.ingress.tls: [{hosts: [internal.example], secretName: example-tls}]
+ asserts:
+ - matchRegexRaw:
+ pattern: "https://internal.example/"
+ - it: names a strict NodePort entry lacking an external policy rule without failing
+ set:
+ mode: strict
+ server.service.type: NodePort
+ asserts:
+ - matchRegexRaw:
+ pattern: "Server has an external entry, but NetworkPolicy has no external ingress rule"
+ - matchRegexRaw:
+ pattern: "Add networkPolicy.server.extraIngress"
+
+ - it: names a strict hostless Ingress lacking an external policy rule
+ set:
+ mode: strict
+ server.ingress.enabled: true
+ asserts:
+ - matchRegexRaw:
+ pattern: "Server has an external entry, but NetworkPolicy has no external ingress rule"
+
+ - it: accepts native allow-all rules without the missing-rule hint
+ set:
+ mode: strict
+ server.service.type: NodePort
+ server.ingress.enabled: true
+ networkPolicy.server.extraIngress: [{}]
+ asserts:
+ - notMatchRegexRaw:
+ pattern: "Server has an external entry, but NetworkPolicy has no external ingress rule"
+
+ - it: does not show policy advice when an explicit override disables policies
+ set:
+ mode: strict
+ server.service.type: NodePort
+ networkPolicy.enabled: false
+ asserts:
+ - notMatchRegexRaw:
+ pattern: "NetworkPolicy has no external ingress rule"
+
+ - it: names the outside discovery egress setting for strict Hubble
+ set:
+ mode: strict
+ server.advertiseUrl: http://external.example:8080
+ asserts:
+ - matchRegexRaw:
+ pattern: "also add networkPolicy.hubble.extraEgress"
+ - it: names a strict ClusterIP with externalIPs lacking an external policy rule
+ set:
+ mode: strict
+ server.service.externalIPs: [192.0.2.10]
+ asserts:
+ - matchRegexRaw:
+ pattern: "Server has an external entry, but NetworkPolicy has no external ingress rule"
+ - matchRegexRaw:
+ pattern: "allows all sources and ports"
diff --git a/helm/hugegraph/tests/selector_label_types_test.yaml b/helm/hugegraph/tests/selector_label_types_test.yaml
new file mode 100644
index 0000000000..f53920987d
--- /dev/null
+++ b/helm/hugegraph/tests/selector_label_types_test.yaml
@@ -0,0 +1,46 @@
+#
+# 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.
+#
+
+# Label values must stay strings. An all-digit release name or a nameOverride
+# such as "true" would otherwise render as a YAML number or boolean, and the
+# API rejects a label map that is not map[string]string.
+suite: Selector label values render as strings
+templates:
+ - server-deployment.yaml
+ - pd-statefulset.yaml
+tests:
+ - it: quotes an all-digit release name
+ release:
+ name: "123"
+ asserts:
+ - equal:
+ path: metadata.labels["app.kubernetes.io/instance"]
+ value: "123"
+ - equal:
+ path: spec.selector.matchLabels["app.kubernetes.io/instance"]
+ value: "123"
+
+ - it: quotes a boolean-looking nameOverride
+ set:
+ nameOverride: "true"
+ asserts:
+ - equal:
+ path: spec.selector.matchLabels["app.kubernetes.io/name"]
+ value: "true"
+ - equal:
+ path: spec.template.metadata.labels["app.kubernetes.io/name"]
+ value: "true"
diff --git a/helm/hugegraph/tests/server_auth_checksum_test.yaml b/helm/hugegraph/tests/server_auth_checksum_test.yaml
new file mode 100644
index 0000000000..b25b6a67c8
--- /dev/null
+++ b/helm/hugegraph/tests/server_auth_checksum_test.yaml
@@ -0,0 +1,66 @@
+#
+# 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.
+#
+
+suite: Server auth checksum and secret wiring
+templates:
+ - server-deployment.yaml
+tests:
+ - it: stamps a checksum/auth annotation on the pod template
+ asserts:
+ - matchRegex:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ pattern: "^[a-f0-9]{64}$"
+
+ - it: keeps a valid checksum when an external admin Secret is supplied
+ set:
+ server.auth.admin.existingSecret: ext-admin-secret
+ server.auth.admin.key: password
+ asserts:
+ - matchRegex:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ pattern: "^[a-f0-9]{64}$"
+
+ - it: never places credential material in pod metadata
+ set:
+ server.auth.admin.password: sup3rs3cr3tpw
+ server.auth.token.value: t0k3nvalu3PADDINGtoREACH32charsMIN
+ asserts:
+ - notMatchRegex:
+ path: spec.template.metadata.annotations["checksum/auth"]
+ pattern: "(sup3rs3cr3tpw|t0k3nvalu3PADDING)"
+
+ - it: sources the admin password from a Secret, never a literal value
+ set:
+ server.auth.admin.password: sup3rs3cr3tpw
+ asserts:
+ - exists:
+ path: spec.template.spec.containers[0].env[?(@.name=="PASSWORD")].valueFrom.secretKeyRef
+ - notExists:
+ path: spec.template.spec.containers[0].env[?(@.name=="PASSWORD")].value
+
+ - it: points the admin Secret ref at an operator-supplied Secret when set
+ set:
+ server.auth.admin.existingSecret: ext-admin-secret
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[?(@.name=="PASSWORD")].valueFrom.secretKeyRef.name
+ value: ext-admin-secret
+
+ - it: shares one JWT signing key across replicas via a Secret ref
+ asserts:
+ - exists:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_AUTH_TOKEN_SECRET")].valueFrom.secretKeyRef
diff --git a/helm/hugegraph/tests/server_discovery_test.yaml b/helm/hugegraph/tests/server_discovery_test.yaml
new file mode 100644
index 0000000000..5685d719a6
--- /dev/null
+++ b/helm/hugegraph/tests/server_discovery_test.yaml
@@ -0,0 +1,132 @@
+#
+# 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.
+#
+
+suite: Server replica discovery announced to PD
+templates:
+ - server-deployment.yaml
+tests:
+ - it: injects the pod IP through the downward API
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[?(@.name=="POD_IP")].valueFrom.fieldRef.fieldPath
+ value: status.podIP
+
+ - it: announces each pod's own address to PD by default
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_URLS_TO_PD")].value
+ pattern: "^http://\\$\\(POD_IP\\):8080$"
+
+ - it: announces the shared advertiseUrl instead when one is set
+ set:
+ server.advertiseUrl: http://graph.example.com:30080
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_URLS_TO_PD")].value
+ value: http://graph.example.com:30080
+
+ # The wrapper always registers server.urls_to_pd; the image would let an
+ # extraEnv value turn that off again or crash-loop the Pod on a bad flag.
+ - it: refuses an operator extraEnv that would override the PD registration flag
+ set:
+ server.extraEnv:
+ - name: HG_SERVER_DEPLOY_IN_K8S
+ value: "false"
+ asserts:
+ - failedTemplate:
+ errorPattern: "server.extraEnv must not set the chart-managed variable HG_SERVER_DEPLOY_IN_K8S"
+
+ - it: refuses an operator extraEnv that would shadow POD_IP
+ set:
+ server.extraEnv:
+ - name: POD_IP
+ value: 10.0.0.1
+ asserts:
+ - failedTemplate:
+ errorPattern: "POD_IP"
+
+ - it: preserves the image service identity by default
+ asserts:
+ - notExists:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")]
+ - notExists:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_GRAPHSPACE")]
+
+ - it: selects a non-default service identity
+ set:
+ server.serviceId: analytics
+ server.graphSpace: team_a
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")].value
+ value: analytics
+ - equal:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_GRAPHSPACE")].value
+ value: team_a
+
+ - it: refuses an extraEnv that shadows the service ID
+ set:
+ server.extraEnv:
+ - name: HG_SERVER_SERVICE_ID
+ value: shadow
+ asserts:
+ - failedTemplate:
+ errorPattern: "HG_SERVER_SERVICE_ID"
+
+ - it: refuses an extraEnv that shadows the graph space
+ set:
+ server.extraEnv:
+ - name: HG_SERVER_GRAPHSPACE
+ value: shadow
+ asserts:
+ - failedTemplate:
+ errorPattern: "HG_SERVER_GRAPHSPACE"
+
+ - it: retains a service name containing spaces
+ set:
+ server.serviceId: ops team
+ server.graphSpace: team_a
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")].value
+ value: ops team
+
+ - it: refuses an extraEnv that shadows the legacy service ID
+ set:
+ server.extraEnv:
+ - name: SERVICE_ID
+ value: shadow
+ asserts:
+ - failedTemplate:
+ errorPattern: "SERVICE_ID"
+
+ - it: refuses an extraEnv that shadows the legacy graph space
+ set:
+ server.extraEnv:
+ - name: GRAPH_SPACE
+ value: shadow
+ asserts:
+ - failedTemplate:
+ errorPattern: "GRAPH_SPACE"
+
+ - it: escapes all dollars so Kubernetes retains a literal service ID
+ set:
+ server.serviceId: 'x-$(POD_IP)-$$(POD_IP)-$(UNKNOWN)-$name-$'
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_SERVER_SERVICE_ID")].value
+ value: 'x-$$(POD_IP)-$$$$(POD_IP)-$$(UNKNOWN)-$$name-$$'
diff --git a/helm/hugegraph/tests/server_exposure_test.yaml b/helm/hugegraph/tests/server_exposure_test.yaml
new file mode 100644
index 0000000000..61bd755edf
--- /dev/null
+++ b/helm/hugegraph/tests/server_exposure_test.yaml
@@ -0,0 +1,72 @@
+#
+# 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.
+#
+
+suite: Server Service exposure and mode defaults
+templates:
+ - server-service.yaml
+tests:
+ - it: defaults to an automatic NodePort in easy mode
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ - notExists:
+ path: spec.ports[0].nodePort
+
+ - it: defaults to ClusterIP in strict mode
+ set:
+ mode: strict
+ asserts:
+ - equal:
+ path: spec.type
+ value: ClusterIP
+
+ - it: honors an explicit NodePort in strict mode without acknowledgement
+ set:
+ mode: strict
+ server.service.type: NodePort
+ server.service.nodePort: 8802
+ asserts:
+ - equal:
+ path: spec.type
+ value: NodePort
+ - equal:
+ path: spec.ports[0].nodePort
+ value: 8802
+
+ - it: renders LoadBalancer without acknowledgement
+ set:
+ server.service.type: LoadBalancer
+ asserts:
+ - equal:
+ path: spec.type
+ value: LoadBalancer
+
+ - it: honors an explicit ClusterIP in easy mode
+ set:
+ server.service.type: ClusterIP
+ asserts:
+ - equal:
+ path: spec.type
+ value: ClusterIP
+
+ - it: treats zero NodePort as automatic allocation
+ set:
+ server.service.nodePort: 0
+ asserts:
+ - notExists:
+ path: spec.ports[0].nodePort
diff --git a/helm/hugegraph/tests/server_readiness_path_test.yaml b/helm/hugegraph/tests/server_readiness_path_test.yaml
new file mode 100644
index 0000000000..aa4eeecfdf
--- /dev/null
+++ b/helm/hugegraph/tests/server_readiness_path_test.yaml
@@ -0,0 +1,46 @@
+#
+# 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.
+#
+
+suite: Server readiness path
+tests:
+ - it: reads all three Server probes from /versions by default
+ template: server-deployment.yaml
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].readinessProbe.httpGet.path
+ value: /versions
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.httpGet.path
+ value: /versions
+ - equal:
+ path: spec.template.spec.containers[0].livenessProbe.httpGet.path
+ value: /versions
+
+ - it: moves only the Server readiness probe when server.readinessPath targets /readiness
+ template: server-deployment.yaml
+ set:
+ server.readinessPath: /readiness
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].readinessProbe.httpGet.path
+ value: /readiness
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.httpGet.path
+ value: /versions
+ - equal:
+ path: spec.template.spec.containers[0].livenessProbe.httpGet.path
+ value: /versions
diff --git a/helm/hugegraph/tests/server_startup_timeout_test.yaml b/helm/hugegraph/tests/server_startup_timeout_test.yaml
new file mode 100644
index 0000000000..e7cafde287
--- /dev/null
+++ b/helm/hugegraph/tests/server_startup_timeout_test.yaml
@@ -0,0 +1,147 @@
+#
+# 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.
+#
+
+suite: Server startup timeout and explicit probe budgets
+templates:
+ - server-deployment.yaml
+tests:
+ - it: derives a 450 second probe budget for the default 300 second storage wait
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: WAIT_STORAGE_TIMEOUT_S
+ value: "300"
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: "150"
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 91
+
+ - it: coordinates an unspecified probe threshold with an extended storage wait
+ set:
+ server.storageWaitTimeoutSeconds: 600
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: WAIT_STORAGE_TIMEOUT_S
+ value: "600"
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 151
+
+ - it: retains an explicit sufficient startup threshold
+ set:
+ server.probes.startup.failureThreshold: 200
+ server.probes.startup.periodSeconds: 10
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: "1690"
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 200
+
+ - it: refuses a conflicting explicit threshold instead of silently increasing it
+ set:
+ server.probes.startup.failureThreshold: 1
+ server.probes.startup.periodSeconds: 5
+ asserts:
+ - failedTemplate:
+ errorPattern: "server.probes.startup"
+
+ - it: accepts the minimum 120 second Server startup budget after storage waiting
+ set:
+ server.probes.startup.failureThreshold: 85
+ server.probes.startup.periodSeconds: 5
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 85
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: "120"
+
+ - it: derives and rounds the unspecified threshold for a large probe period
+ set:
+ server.probes.startup.periodSeconds: 449
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 3
+
+ - it: keeps an explicitly configured initial delay
+ set:
+ server.probes.startup.initialDelaySeconds: 600
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.initialDelaySeconds
+ value: 600
+
+ - it: caps the image start budget at the entrypoint maximum without rewriting the threshold
+ set:
+ server.probes.startup.failureThreshold: 100000
+ server.probes.startup.periodSeconds: 10
+ asserts:
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: "86400"
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 100000
+
+ - it: refuses an extraEnv override that diverges from the startup probe
+ set:
+ server.extraEnv:
+ - name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: "60"
+ asserts:
+ - failedTemplate:
+ errorMessage: server.extraEnv must not set the chart-managed variable HG_SERVER_STARTUP_TIMEOUT_S
+
+ - it: refuses a storage wait override through chart-owned environment variables
+ set:
+ server.extraEnv:
+ - name: WAIT_STORAGE_TIMEOUT_S
+ value: "60"
+ asserts:
+ - failedTemplate:
+ errorPattern: "server.extraEnv must not set the chart-managed variable WAIT_STORAGE_TIMEOUT_S"
+
+ - it: accepts an explicit delayed first probe with a sufficient total budget
+ set:
+ server.probes.startup.initialDelaySeconds: 600
+ server.probes.startup.failureThreshold: 1
+ asserts:
+ - equal:
+ path: spec.template.spec.containers[0].startupProbe.failureThreshold
+ value: 1
+ - contains:
+ path: spec.template.spec.containers[0].env
+ content:
+ name: HG_SERVER_STARTUP_TIMEOUT_S
+ value: "300"
diff --git a/helm/hugegraph/tests/statefulset_hardening_test.yaml b/helm/hugegraph/tests/statefulset_hardening_test.yaml
new file mode 100644
index 0000000000..aecbdda335
--- /dev/null
+++ b/helm/hugegraph/tests/statefulset_hardening_test.yaml
@@ -0,0 +1,51 @@
+#
+# 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.
+#
+
+suite: StatefulSet update and PVC retention hardening
+templates:
+ - pd-statefulset.yaml
+ - store-statefulset.yaml
+tests:
+ - it: declares an explicit updateStrategy
+ asserts:
+ - equal:
+ path: spec.updateStrategy.type
+ value: RollingUpdate
+
+ - it: retains PVCs on delete and scale-down
+ asserts:
+ - equal:
+ path: spec.persistentVolumeClaimRetentionPolicy.whenDeleted
+ value: Retain
+ - equal:
+ path: spec.persistentVolumeClaimRetentionPolicy.whenScaled
+ value: Retain
+
+ - it: uses Parallel pod management for raft bring-up
+ asserts:
+ - equal:
+ path: spec.podManagementPolicy
+ value: Parallel
+
+ - it: honours an OnDelete updateStrategy override
+ set:
+ pd.updateStrategy.type: OnDelete
+ store.updateStrategy.type: OnDelete
+ asserts:
+ - equal:
+ path: spec.updateStrategy.type
+ value: OnDelete
diff --git a/helm/hugegraph/tests/termination_grace_period_test.yaml b/helm/hugegraph/tests/termination_grace_period_test.yaml
new file mode 100644
index 0000000000..0c65493b95
--- /dev/null
+++ b/helm/hugegraph/tests/termination_grace_period_test.yaml
@@ -0,0 +1,35 @@
+#
+# 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.
+#
+
+suite: Workload termination grace period overrides
+templates:
+ - pd-statefulset.yaml
+ - store-statefulset.yaml
+ - server-deployment.yaml
+ - hubble-deployment.yaml
+tests:
+ - it: preserves explicit zero grace periods
+ set:
+ hubble.enabled: true
+ pd.terminationGracePeriodSeconds: 0
+ store.terminationGracePeriodSeconds: 0
+ server.terminationGracePeriodSeconds: 0
+ hubble.terminationGracePeriodSeconds: 0
+ asserts:
+ - equal:
+ path: spec.template.spec.terminationGracePeriodSeconds
+ value: 0
diff --git a/helm/hugegraph/tests/test_hook_gremlin_test.yaml b/helm/hugegraph/tests/test_hook_gremlin_test.yaml
new file mode 100644
index 0000000000..1e40618b98
--- /dev/null
+++ b/helm/hugegraph/tests/test_hook_gremlin_test.yaml
@@ -0,0 +1,162 @@
+#
+# 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.
+#
+
+suite: Helm test checks Gremlin on every Server Pod
+templates:
+ - tests/test-connection.yaml
+ - server-service-headless.yaml
+tests:
+ - it: renders a headless Server Service for per-Pod enumeration
+ template: server-service-headless.yaml
+ asserts:
+ - equal:
+ path: spec.clusterIP
+ value: None
+ - notExists:
+ path: spec.publishNotReadyAddresses
+ - equal:
+ path: spec.selector["app.kubernetes.io/component"]
+ value: server
+ - equal:
+ path: metadata.name
+ value: RELEASE-NAME-hugegraph-server-headless
+
+ - it: keeps the headless name inside the DNS label limit under a long override
+ template: server-service-headless.yaml
+ set:
+ fullnameOverride: an-extremely-long-fullname-override-that-hits-the-63-char-limit
+ asserts:
+ - matchRegex:
+ path: metadata.name
+ pattern: "^.{1,63}$"
+
+ - it: runs a graph-bound Gremlin check against the headless endpoints
+ template: tests/test-connection.yaml
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "RELEASE-NAME-hugegraph-server-headless"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "/gremlin"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "DEFAULT-hugegraph"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "FLOOR=\"3\""
+
+ - it: checks the explicitly selected graph in a non-default graph space
+ template: tests/test-connection.yaml
+ set:
+ server.graphSpace: team_a
+ server.testGraphAlias: team_a-demo
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: '"graph":"team_a-demo"'
+ - notMatchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "DEFAULT-hugegraph"
+
+ - it: preserves the default binding when graphSpace changes without a selected graph
+ template: tests/test-connection.yaml
+ set:
+ server.graphSpace: team_a
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: '"graph":"DEFAULT-hugegraph"'
+
+ - it: preserves a static graph binding with an uppercase local file name
+ template: tests/test-connection.yaml
+ set:
+ server.testGraphAlias: DEFAULT-Demo
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: '"graph":"DEFAULT-Demo"'
+
+ - it: defaults a null alias for both index and business checks
+ template: tests/test-connection.yaml
+ set:
+ server.testGraphAlias: null
+ server.testGremlin: "assert graph.traversal().V().count().next() == 40"
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: '(?m)^\s*BODY=.*"graph":"DEFAULT-hugegraph"'
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: '(?m)^\s*BUSINESS_BODY=.*"graph":"DEFAULT-hugegraph"'
+
+ - it: floors the per-Pod count at the HPA minimum when HPA is enabled
+ template: tests/test-connection.yaml
+ set:
+ server.hpa.enabled: true
+ server.hpa.minReplicas: 5
+ server.resources.requests.cpu: 500m
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "FLOOR=\"5\""
+
+ - it: checks linked indexes by ID without refreshing the full index cache
+ template: tests/test-connection.yaml
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "getVertexLabels.*getEdgeLabels"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "graph.indexLabel"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "index.status.*ok"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "CREATED expected"
+ - notMatchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "getIndexLabels"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "business assertions skipped"
+
+ - it: sends optional business assertions on each Pod
+ template: tests/test-connection.yaml
+ set:
+ server.testGremlin: "assert graph.traversal().V().count().next() == 40"
+ asserts:
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "assert graph.traversal"
+ - matchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "business assertion"
+
+ - it: sends no credentials when authentication is disabled
+ template: tests/test-connection.yaml
+ set:
+ hubble.enabled: false
+ server.auth.enabled: false
+ asserts:
+ - notMatchRegex:
+ path: spec.containers[0].command[2]
+ pattern: "Authorization"
+ - notExists:
+ path: spec.containers[0].env
diff --git a/helm/hugegraph/tests/test_hook_resources_test.yaml b/helm/hugegraph/tests/test_hook_resources_test.yaml
new file mode 100644
index 0000000000..e96af6ba22
--- /dev/null
+++ b/helm/hugegraph/tests/test_hook_resources_test.yaml
@@ -0,0 +1,55 @@
+#
+# 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.
+#
+
+suite: Helm test hook is resource bounded
+templates:
+ - tests/test-connection.yaml
+tests:
+ - it: bounds the test hook by default so quota-managed namespaces accept it
+ asserts:
+ - exists:
+ path: spec.containers[0].resources.limits.cpu
+ - exists:
+ path: spec.containers[0].resources.limits.memory
+ - exists:
+ path: spec.containers[0].resources.requests.cpu
+ - exists:
+ path: spec.containers[0].resources.requests.memory
+
+ - it: lets an operator override the hook resources
+ set:
+ server.testResources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ asserts:
+ - equal:
+ path: spec.containers[0].resources.limits.cpu
+ value: 500m
+
+ - it: passes imagePullSecrets to the test hook Pod
+ set:
+ imagePullSecrets:
+ - name: registry-creds
+ asserts:
+ - contains:
+ path: spec.imagePullSecrets
+ content:
+ name: registry-creds
diff --git a/helm/hugegraph/tests/topology_quorum_test.yaml b/helm/hugegraph/tests/topology_quorum_test.yaml
new file mode 100644
index 0000000000..1069ed196d
--- /dev/null
+++ b/helm/hugegraph/tests/topology_quorum_test.yaml
@@ -0,0 +1,109 @@
+#
+# 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.
+#
+
+suite: PD topology, quorum math and PDB gating
+tests:
+ - it: points the PD StatefulSet at its own headless Service
+ template: pd-statefulset.yaml
+ asserts:
+ - matchRegex:
+ path: spec.serviceName
+ pattern: "-hugegraph-pd$"
+
+ - it: builds a DNS-based raft peer list that tracks pd.replicas
+ template: pd-statefulset.yaml
+ set:
+ pd.replicas: 3
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_PD_RAFT_PEERS_LIST")].value
+ pattern: "pd-0\\..*svc.*pd-1\\..*svc.*pd-2\\..*svc"
+
+ - it: shrinks the raft peer list for a single-node install
+ template: pd-statefulset.yaml
+ set:
+ pd.replicas: 1
+ pd.partition.defaultShardCount: 1
+ store.replicas: 1
+ asserts:
+ - notMatchRegex:
+ path: spec.template.spec.containers[0].env[?(@.name=="HG_PD_RAFT_PEERS_LIST")].value
+ pattern: "pd-1\\."
+
+ - it: waits for a majority of 2 when PD has 3 replicas
+ template: store-statefulset.yaml
+ set:
+ pd.replicas: 3
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.initContainers[0].command[2]
+ pattern: "REQUIRED=2"
+
+ - it: waits for a majority of 3 when PD has 5 replicas
+ template: store-statefulset.yaml
+ set:
+ pd.replicas: 5
+ pd.pdb.minAvailable: 3
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.initContainers[0].command[2]
+ pattern: "REQUIRED=3"
+
+ - it: waits for a majority of 1 when PD is single-node
+ template: store-statefulset.yaml
+ set:
+ pd.replicas: 1
+ pd.partition.defaultShardCount: 1
+ store.replicas: 1
+ asserts:
+ - matchRegex:
+ path: spec.template.spec.initContainers[0].command[2]
+ pattern: "REQUIRED=1"
+
+ - it: emits no PodDisruptionBudget for a single-replica PD
+ template: pd-pdb.yaml
+ set:
+ pd.replicas: 1
+ pd.partition.defaultShardCount: 1
+ store.replicas: 1
+ asserts:
+ - hasDocuments:
+ count: 0
+
+ - it: publishes not-ready addresses so peer DNS resolves before readiness
+ template: pd-service-headless.yaml
+ asserts:
+ - equal:
+ path: spec.publishNotReadyAddresses
+ value: true
+ - equal:
+ path: spec.clusterIP
+ value: None
+
+ # The PD replica guard reads the live StatefulSet, which a render never has,
+ # so a fresh install at any supported replica count must pass. The guarded
+ # cases, growing or shrinking an initialized group, need a cluster and
+ # cannot be reached from a render.
+ - it: leaves a fresh install at five PD replicas alone
+ template: pd-statefulset.yaml
+ set:
+ pd.replicas: 5
+ pd.pdb.minAvailable: 3
+ asserts:
+ - equal:
+ path: spec.replicas
+ value: 5
diff --git a/helm/hugegraph/tests/upgrade_entry_notice_test.yaml b/helm/hugegraph/tests/upgrade_entry_notice_test.yaml
new file mode 100644
index 0000000000..aeb5b31556
--- /dev/null
+++ b/helm/hugegraph/tests/upgrade_entry_notice_test.yaml
@@ -0,0 +1,90 @@
+#
+# 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.
+#
+
+suite: Upgrade entry default notice
+templates:
+ - NOTES.txt
+tests:
+ - it: reports both inherited NodePort entries during an easy upgrade
+ release:
+ upgrade: true
+ asserts:
+ - matchRegexRaw:
+ pattern: "Upgrade notice: chart 0.2.0 changed entry defaults"
+ - matchRegexRaw:
+ pattern: "inherits NodePort for Server, Hubble"
+ - matchRegexRaw:
+ pattern: "docs/operations.md#storage-and-upgrades"
+
+ - it: keeps fresh-install notes free of an upgrade notice
+ release:
+ upgrade: false
+ asserts:
+ - notMatchRegexRaw:
+ pattern: "Upgrade notice:"
+
+ - it: omits the disabled Hubble entry from the upgrade notice
+ release:
+ upgrade: true
+ set:
+ hubble.enabled: false
+ asserts:
+ - matchRegexRaw:
+ pattern: "inherits NodePort for Server\\."
+ - notMatchRegexRaw:
+ pattern: "inherits NodePort for Server, Hubble"
+
+ - it: only names the inherited Hubble entry with an explicit internal Server
+ release:
+ upgrade: true
+ set:
+ server.service.type: ClusterIP
+ asserts:
+ - matchRegexRaw:
+ pattern: "inherits NodePort for Hubble"
+ - notMatchRegexRaw:
+ pattern: "inherits NodePort for Server"
+
+ - it: accepts explicit NodePort entries without the inherited-default notice
+ release:
+ upgrade: true
+ set:
+ server.service.type: NodePort
+ hubble.service.type: NodePort
+ asserts:
+ - notMatchRegexRaw:
+ pattern: "Upgrade notice:"
+
+ - it: preserves explicit easy-mode internal entries without the notice
+ release:
+ upgrade: true
+ set:
+ server.service.type: ClusterIP
+ hubble.service.type: ClusterIP
+ asserts:
+ - notMatchRegexRaw:
+ pattern: "Upgrade notice:"
+
+ - it: does not report an easy-default change for a strict-mode upgrade
+ release:
+ upgrade: true
+ set:
+ mode: strict
+ server.service.type: NodePort
+ asserts:
+ - notMatchRegexRaw:
+ pattern: "Upgrade notice:"
diff --git a/helm/hugegraph/tests/validate_values_test.yaml b/helm/hugegraph/tests/validate_values_test.yaml
new file mode 100644
index 0000000000..7f83a4779d
--- /dev/null
+++ b/helm/hugegraph/tests/validate_values_test.yaml
@@ -0,0 +1,262 @@
+#
+# 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.
+#
+
+suite: validateValues guard rails
+templates:
+ - server-deployment.yaml
+tests:
+ - it: rejects a PDB that would let PD drop below raft majority
+ set:
+ pd.pdb.minAvailable: 1
+ asserts:
+ - failedTemplate:
+ errorPattern: "pd.pdb.minAvailable must be at least the PD Raft majority"
+
+ - it: rejects a PDB that would permanently block drains
+ set:
+ pd.pdb.minAvailable: 3
+ asserts:
+ - failedTemplate:
+ errorPattern: "must be less than pd.replicas"
+
+ - it: rejects an even shard count
+ set:
+ pd.partition.defaultShardCount: 2
+ asserts:
+ - failedTemplate:
+ errorPattern: "must be odd"
+
+ - it: rejects a shard count above store.replicas
+ set:
+ pd.partition.defaultShardCount: 5
+ asserts:
+ - failedTemplate:
+ errorPattern: "greater than store.replicas"
+
+ - it: rejects operator overrides of the chart-managed JAVA_OPTIONS
+ set:
+ server.extraEnv:
+ - name: JAVA_OPTIONS
+ value: "-Xmx1g"
+ asserts:
+ - failedTemplate:
+ errorPattern: "must not set the chart-managed variable JAVA_OPTIONS"
+
+ - it: accepts a PD NodePort under NetworkPolicy without pd extraIngress
+ set:
+ networkPolicy.enabled: true
+ pd.service.type: NodePort
+ pd.service.allowInsecureExposure: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a PD LoadBalancer under NetworkPolicy without pd extraIngress
+ set:
+ networkPolicy.enabled: true
+ pd.service.type: LoadBalancer
+ pd.service.allowInsecureExposure: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a Server NodePort under NetworkPolicy without server extraIngress
+ set:
+ networkPolicy.enabled: true
+ server.service.type: NodePort
+ server.service.allowInsecureExposure: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a Server LoadBalancer under NetworkPolicy without server extraIngress
+ set:
+ networkPolicy.enabled: true
+ server.service.type: LoadBalancer
+ server.service.allowInsecureExposure: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a Server Ingress under NetworkPolicy without server extraIngress
+ set:
+ networkPolicy.enabled: true
+ server.ingress.enabled: true
+ server.ingress.allowPlainHttp: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a Server advertiseUrl under NetworkPolicy without server extraIngress
+ set:
+ networkPolicy.enabled: true
+ server.advertiseUrl: http://hg.example.com:8080
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a Hubble NodePort under NetworkPolicy without hubble extraIngress
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ hubble.service.type: NodePort
+ hubble.service.allowInsecureExposure: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a Hubble LoadBalancer under NetworkPolicy without hubble extraIngress
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ hubble.service.type: LoadBalancer
+ hubble.service.allowInsecureExposure: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a Hubble Ingress under NetworkPolicy without hubble extraIngress
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ hubble.ingress.enabled: true
+ hubble.ingress.allowPlainHttp: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts an advertised Server URL under explicit NetworkPolicy
+ set:
+ networkPolicy.enabled: true
+ server.advertiseUrl: http://hg.example.com:8080
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts Server exposure without an acknowledgement or source confirmation
+ set:
+ networkPolicy.enabled: true
+ server.service.type: NodePort
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts PD exposure without an acknowledgement or source confirmation
+ set:
+ networkPolicy.enabled: true
+ pd.service.type: NodePort
+ asserts:
+ - notFailedTemplate: {}
+ - it: leaves exposure alone while NetworkPolicy is off
+ set:
+ server.service.type: LoadBalancer
+ server.service.allowInsecureExposure: true
+ server.advertiseUrl: http://hg.example.com:8080
+ asserts:
+ - hasDocuments:
+ count: 1
+
+ - it: ignores Hubble exposure settings while Hubble is disabled
+ set:
+ networkPolicy.enabled: true
+ hubble.service.type: NodePort
+ asserts:
+ - hasDocuments:
+ count: 1
+
+ - it: rejects Hubble without Server auth
+ set:
+ server.auth.enabled: false
+ hubble.enabled: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "hubble.enabled requires server.auth"
+
+ - it: accepts a plain-HTTP Hubble Ingress
+ set:
+ hubble.ingress.enabled: true
+ hubble.enabled: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: rejects an empty Hubble image tag
+ set:
+ hubble.image.tag: ""
+ hubble.enabled: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "hubble.image needs a tag or a digest"
+
+ - it: rejects a Store PDB that permits two concurrent evictions
+ set:
+ store.replicas: 4
+ asserts:
+ - failedTemplate:
+ errorPattern: "store.pdb.minAvailable must be at least store.replicas - 1"
+
+ - it: rejects podLabels that overwrite a selector label
+ set:
+ store.podLabels:
+ app.kubernetes.io/component: hacked
+ asserts:
+ - failedTemplate:
+ errorPattern: "store.podLabels must not set app.kubernetes.io/component"
+
+ - it: accepts a TLS-less Server Ingress without the plain-HTTP opt-in
+ set:
+ server.ingress.enabled: true
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts a non-ClusterIP PD Service without the exposure acknowledgement
+ set:
+ pd.service.type: NodePort
+ asserts:
+ - notFailedTemplate: {}
+ - it: rejects a read-only root filesystem on Hubble
+ set:
+ hubble.enabled: true
+ hubble.securityContext.readOnlyRootFilesystem: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "readOnlyRootFilesystem=true breaks Hubble"
+
+ - it: rejects a read-only root filesystem on Server
+ set:
+ server.securityContext.readOnlyRootFilesystem: true
+ asserts:
+ - failedTemplate:
+ errorPattern: "readOnlyRootFilesystem=true breaks Server"
+
+ - it: rejects a Server pod annotation under the chart-owned checksum prefix
+ set:
+ server.podAnnotations:
+ checksum/auth: pinned
+ asserts:
+ - failedTemplate:
+ errorPattern: "server.podAnnotations must not set checksum/auth"
+
+ - it: rejects a PD pod annotation under the chart-owned checksum prefix
+ set:
+ pd.podAnnotations:
+ checksum/pd-auth: pinned
+ asserts:
+ - failedTemplate:
+ errorPattern: "pd.podAnnotations must not set checksum/pd-auth"
+
+ - it: accepts an advertised Server URL that Hubble's egress policy would block
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ server.advertiseUrl: https://hg.example.com
+ networkPolicy.server.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ asserts:
+ - notFailedTemplate: {}
+ - it: accepts an advertised Server URL once Hubble egress names it
+ set:
+ networkPolicy.enabled: true
+ hubble.enabled: true
+ server.advertiseUrl: https://hg.example.com
+ networkPolicy.server.extraIngress:
+ - from:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ networkPolicy.hubble.extraEgress:
+ - to:
+ - ipBlock:
+ cidr: 10.0.0.0/8
+ asserts:
+ - notFailedTemplate: {}
diff --git a/helm/hugegraph/values-cluster.yaml b/helm/hugegraph/values-cluster.yaml
new file mode 100644
index 0000000000..c9b39bd3d9
--- /dev/null
+++ b/helm/hugegraph/values-cluster.yaml
@@ -0,0 +1,131 @@
+#
+# 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.
+#
+
+# Multi-node starting point: 3 Server + 3 PD + 3 Store.
+# Size this profile again for the real graph, traffic, and failure budget.
+# Usage: helm install hg ./helm/hugegraph -f values-cluster.yaml
+# Entry and network policy inherit mode (easy by default).
+# Add --set mode=strict for ClusterIP and NetworkPolicy, or override either explicitly.
+
+pd:
+ replicas: 3
+ javaOpts: >-
+ -Xms256m -Xmx512m
+ -XX:MaxMetaspaceSize=256m
+ -XX:MaxDirectMemorySize=256m
+ -XX:+UseContainerSupport
+ resources:
+ requests:
+ cpu: 500m
+ memory: 1Gi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ # Keep PD quorum members on separate nodes.
+ antiAffinity: required
+ pdb:
+ enabled: true
+ minAvailable: 2
+ storage:
+ size: 10Gi
+
+store:
+ replicas: 3
+ # Store readiness is listener-only (/v1/health), so an automatic rolling
+ # update can replace the next Store while the previous one is still
+ # restoring its partitions. OnDelete stops automatic advancement; it is
+ # not by itself a safety proof: the manual one-at-a-time procedure with
+ # its per-group database checks (README, Upgrading) still applies.
+ updateStrategy:
+ type: OnDelete
+ javaOpts: >-
+ -Xms512m -Xmx1024m
+ -XX:MaxMetaspaceSize=256m
+ -XX:MaxDirectMemorySize=512m
+ -XX:+UseContainerSupport
+ # Container limits do not configure JVM heap or RocksDB native cache budgets.
+ # Size this preset for the workload and configure those application budgets
+ # separately; javaOpts accepts explicit JVM/system-property overrides.
+ resources:
+ requests:
+ cpu: "1"
+ memory: 5Gi
+ limits:
+ cpu: "4"
+ memory: 8Gi
+ waitResources:
+ requests:
+ cpu: 25m
+ memory: 32Mi
+ limits:
+ cpu: 250m
+ memory: 64Mi
+ # Pinned to "required": production must not co-locate shard replicas.
+ antiAffinity: required
+ pdb:
+ enabled: true
+ minAvailable: 2
+ storage:
+ size: 50Gi
+
+server:
+ replicas: 3
+ javaOpts: >-
+ -Xms512m -Xmx1024m
+ -XX:MaxMetaspaceSize=256m
+ -XX:MaxDirectMemorySize=256m
+ -XX:+UseContainerSupport
+ resources:
+ requests:
+ cpu: "1"
+ memory: 1Gi
+ limits:
+ cpu: "4"
+ memory: 2Gi
+ hpa:
+ enabled: false
+ # Keep at least two Servers through voluntary evictions such as node
+ # drains; the default leaves the PDB off because Server holds no quorum.
+ pdb:
+ enabled: true
+ minAvailable: 2
+ # Auth is on by default (chart-managed admin Secret). Pin it here so a
+ # production overlay cannot accidentally drop authentication.
+ auth:
+ enabled: true
+ admin:
+ autoGenerate: true
+ token:
+ autoGenerate: true
+
+# Hubble is enabled by default. Disable with --set hubble.enabled=false.
+# The optional resource override below remains available when needed.
+#
+# hubble:
+# enabled: true
+# persistence:
+# enabled: true
+# size: 1Gi
+# resources:
+# requests:
+# cpu: 250m
+# memory: 768Mi
+# limits:
+# cpu: "1"
+# memory: 1536Mi
+
+# Network isolation is selected by mode or an explicit enabled override.
diff --git a/helm/hugegraph/values-single.yaml b/helm/hugegraph/values-single.yaml
new file mode 100644
index 0000000000..92f4acf4d5
--- /dev/null
+++ b/helm/hugegraph/values-single.yaml
@@ -0,0 +1,46 @@
+#
+# 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.
+#
+
+# Convenience preset: single-node / local kind-minikube development.
+# Usage: helm install hg ./helm/hugegraph -f values-single.yaml
+
+pd:
+ replicas: 1
+ antiAffinity: disabled
+ pdb:
+ enabled: false
+ storage:
+ size: 5Gi
+
+store:
+ replicas: 1
+ antiAffinity: disabled
+ pdb:
+ enabled: false
+ storage:
+ size: 10Gi
+
+server:
+ replicas: 1
+ hpa:
+ enabled: false
+ auth:
+ enabled: true
+ admin:
+ autoGenerate: true
+ token:
+ autoGenerate: true
diff --git a/helm/hugegraph/values-strict.yaml b/helm/hugegraph/values-strict.yaml
new file mode 100644
index 0000000000..576f79c3bd
--- /dev/null
+++ b/helm/hugegraph/values-strict.yaml
@@ -0,0 +1,20 @@
+#
+# 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.
+#
+
+
+# Optional security defaults. Component values and --set override this mode.
+mode: strict
diff --git a/helm/hugegraph/values.schema.json b/helm/hugegraph/values.schema.json
new file mode 100644
index 0000000000..987e650c16
--- /dev/null
+++ b/helm/hugegraph/values.schema.json
@@ -0,0 +1,1594 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "HugeGraph HStore Helm values",
+ "type": "object",
+ "required": [
+ "pd",
+ "store",
+ "server"
+ ],
+ "properties": {
+ "fullnameOverride": {
+ "type": "string"
+ },
+ "nameOverride": {
+ "type": "string"
+ },
+ "imagePullSecrets": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "pd": {
+ "$ref": "#/definitions/pd"
+ },
+ "store": {
+ "$ref": "#/definitions/store"
+ },
+ "server": {
+ "$ref": "#/definitions/server"
+ },
+ "global": {
+ "type": "object",
+ "properties": {
+ "imageRegistry": {
+ "type": "string"
+ }
+ }
+ },
+ "hubble": {
+ "$ref": "#/definitions/hubble"
+ },
+ "networkPolicy": {
+ "$ref": "#/definitions/networkPolicy"
+ },
+ "mode": {
+ "type": "string",
+ "enum": [
+ "easy",
+ "strict"
+ ]
+ },
+ "persistence": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "definitions": {
+ "networkPolicyPeers": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "networkPolicyIngressRules": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "from": {
+ "$ref": "#/definitions/networkPolicyPeers"
+ }
+ }
+ }
+ },
+ "networkPolicyEgressRules": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "to": {
+ "$ref": "#/definitions/networkPolicyPeers"
+ }
+ }
+ }
+ },
+ "networkPolicyComponent": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "extraIngress": {
+ "$ref": "#/definitions/networkPolicyIngressRules"
+ }
+ }
+ },
+ "networkPolicy": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": {
+ "type": [
+ "boolean",
+ "null"
+ ]
+ },
+ "pd": {
+ "$ref": "#/definitions/networkPolicyComponent"
+ },
+ "store": {
+ "$ref": "#/definitions/networkPolicyComponent"
+ },
+ "server": {
+ "$ref": "#/definitions/networkPolicyComponent"
+ },
+ "hubble": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "extraIngress": {
+ "$ref": "#/definitions/networkPolicyIngressRules"
+ },
+ "extraEgress": {
+ "$ref": "#/definitions/networkPolicyEgressRules"
+ }
+ }
+ }
+ }
+ },
+ "optionalPositiveInteger": {
+ "oneOf": [
+ {
+ "type": "integer",
+ "minimum": 1
+ },
+ {
+ "type": "string",
+ "pattern": "^([1-9][0-9]*)?$"
+ }
+ ]
+ },
+ "image": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "repository",
+ "tag",
+ "pullPolicy"
+ ],
+ "properties": {
+ "repository": {
+ "type": "string",
+ "minLength": 1
+ },
+ "tag": {
+ "type": "string"
+ },
+ "pullPolicy": {
+ "type": "string",
+ "enum": [
+ "Always",
+ "IfNotPresent",
+ "Never"
+ ]
+ },
+ "digest": {
+ "type": "string",
+ "description": "Optional immutable image digest, for example sha256:... Wins over tag when set."
+ }
+ }
+ },
+ "resources": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "requests": {
+ "$ref": "#/definitions/resourceList"
+ },
+ "limits": {
+ "$ref": "#/definitions/resourceList"
+ }
+ }
+ },
+ "updateStrategy": {
+ "type": "object",
+ "additionalProperties": false,
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "type": {
+ "const": "OnDelete"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ "then": {
+ "not": {
+ "required": [
+ "rollingUpdate"
+ ]
+ }
+ }
+ }
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "RollingUpdate",
+ "OnDelete"
+ ]
+ },
+ "rollingUpdate": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "partition": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "maxUnavailable": {
+ "type": "integer",
+ "const": 1
+ }
+ }
+ }
+ }
+ },
+ "persistentVolumeClaimRetentionPolicy": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "whenDeleted": {
+ "type": "string",
+ "enum": [
+ "Retain",
+ "Delete"
+ ]
+ },
+ "whenScaled": {
+ "type": "string",
+ "enum": [
+ "Retain",
+ "Delete"
+ ]
+ }
+ }
+ },
+ "resourceList": {
+ "type": "object",
+ "additionalProperties": {
+ "type": [
+ "string",
+ "number"
+ ]
+ },
+ "properties": {
+ "cpu": {
+ "type": [
+ "string",
+ "number"
+ ]
+ },
+ "memory": {
+ "type": [
+ "string",
+ "number"
+ ]
+ },
+ "ephemeral-storage": {
+ "type": [
+ "string",
+ "number"
+ ]
+ }
+ }
+ },
+ "storage": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "size",
+ "storageClassName"
+ ],
+ "properties": {
+ "size": {
+ "type": "string",
+ "minLength": 1
+ },
+ "storageClassName": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": [
+ "boolean",
+ "null"
+ ]
+ }
+ }
+ },
+ "pdb": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "enabled",
+ "minAvailable"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "minAvailable": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "probe": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "periodSeconds",
+ "failureThreshold"
+ ],
+ "properties": {
+ "periodSeconds": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "failureThreshold": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "timeoutSeconds": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "initialDelaySeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "successThreshold": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "probes": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "startup",
+ "readiness",
+ "liveness"
+ ],
+ "properties": {
+ "startup": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/probe"
+ },
+ {
+ "properties": {
+ "successThreshold": {
+ "maximum": 1
+ }
+ }
+ }
+ ]
+ },
+ "readiness": {
+ "$ref": "#/definitions/probe"
+ },
+ "liveness": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/probe"
+ },
+ {
+ "properties": {
+ "successThreshold": {
+ "maximum": 1
+ }
+ }
+ }
+ ]
+ }
+ }
+ },
+ "ports": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "grpc",
+ "rest",
+ "raft"
+ ],
+ "properties": {
+ "grpc": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ },
+ "rest": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ },
+ "raft": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ }
+ }
+ },
+ "pd": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "replicas",
+ "image",
+ "ports",
+ "dataPath",
+ "storage",
+ "resources",
+ "antiAffinity",
+ "pdb",
+ "probes"
+ ],
+ "properties": {
+ "replicas": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 99
+ },
+ "image": {
+ "$ref": "#/definitions/image"
+ },
+ "javaOpts": {
+ "type": "string"
+ },
+ "raftIpWhitelistEnabled": {
+ "type": "boolean"
+ },
+ "raftRpcTimeoutMs": {
+ "$ref": "#/definitions/optionalPositiveInteger"
+ },
+ "updateStrategy": {
+ "$ref": "#/definitions/updateStrategy"
+ },
+ "persistentVolumeClaimRetentionPolicy": {
+ "$ref": "#/definitions/persistentVolumeClaimRetentionPolicy"
+ },
+ "partition": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "defaultShardCount": {
+ "$ref": "#/definitions/optionalPositiveInteger"
+ },
+ "storeMaxShardCount": {
+ "$ref": "#/definitions/optionalPositiveInteger"
+ }
+ }
+ },
+ "ports": {
+ "$ref": "#/definitions/ports"
+ },
+ "dataPath": {
+ "type": "string",
+ "minLength": 1
+ },
+ "auth": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "value",
+ "existingSecret",
+ "key",
+ "autoGenerate"
+ ],
+ "description": "PD REST Basic-auth secret (auth.secret-key), shared with the Server storage wait and Hubble.",
+ "properties": {
+ "value": {
+ "type": "string",
+ "pattern": "^([\\x21-\\x5b\\x5d-\\x7e]([\\x20-\\x5b\\x5d-\\x7e]*[\\x21-\\x5b\\x5d-\\x7e])?)?$",
+ "description": "Plaintext secret. Empty defers to existingSecret or autoGenerate. Printable ASCII with no backslash, and no leading or trailing space: Commons Configuration trims a properties value, so padding would make the stored secret and the effective one differ."
+ },
+ "existingSecret": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1
+ },
+ "autoGenerate": {
+ "type": "boolean"
+ }
+ },
+ "anyOf": [
+ {
+ "properties": {
+ "existingSecret": {
+ "minLength": 1
+ }
+ }
+ },
+ {
+ "properties": {
+ "value": {
+ "minLength": 1
+ },
+ "existingSecret": {
+ "const": ""
+ }
+ }
+ },
+ {
+ "properties": {
+ "autoGenerate": {
+ "const": true
+ },
+ "existingSecret": {
+ "const": ""
+ }
+ }
+ }
+ ]
+ },
+ "storage": {
+ "$ref": "#/definitions/storage"
+ },
+ "resources": {
+ "$ref": "#/definitions/resources"
+ },
+ "antiAffinity": {
+ "type": "string",
+ "enum": [
+ "required",
+ "preferred",
+ "disabled"
+ ]
+ },
+ "pdb": {
+ "$ref": "#/definitions/pdb"
+ },
+ "probes": {
+ "$ref": "#/definitions/probes"
+ },
+ "podSecurityContext": {
+ "type": "object"
+ },
+ "securityContext": {
+ "type": "object"
+ },
+ "nodeSelector": {
+ "type": "object"
+ },
+ "tolerations": {
+ "type": "array"
+ },
+ "affinity": {
+ "type": "object"
+ },
+ "topologySpreadConstraints": {
+ "type": "array"
+ },
+ "priorityClassName": {
+ "type": "string"
+ },
+ "podAnnotations": {
+ "type": "object"
+ },
+ "podLabels": {
+ "type": "object"
+ },
+ "extraEnv": {
+ "type": "array"
+ },
+ "terminationGracePeriodSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "serviceAccount": {
+ "type": "object",
+ "properties": {
+ "create": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "automountServiceAccountToken": {
+ "type": "boolean"
+ }
+ }
+ },
+ "service": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "ClusterIP",
+ "NodePort",
+ "LoadBalancer"
+ ]
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "allowInsecureExposure": {
+ "type": "boolean",
+ "description": "Legacy compatibility field; does not gate Service exposure."
+ },
+ "restNodePort": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0,
+ "maximum": 65535
+ },
+ "grpcNodePort": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0,
+ "maximum": 65535
+ },
+ "externalTrafficPolicy": {
+ "type": "string",
+ "enum": [
+ "Cluster",
+ "Local"
+ ]
+ },
+ "loadBalancerClass": {
+ "type": "string"
+ },
+ "loadBalancerIP": {
+ "type": "string"
+ },
+ "loadBalancerSourceRanges": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "externalIPs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "allocateLoadBalancerNodePorts": {
+ "type": "boolean"
+ }
+ }
+ },
+ "livenessPath": {
+ "type": "string",
+ "pattern": "^(/.*)?$",
+ "description": "HTTP path for the PD startup and liveness probes. Empty derives it from pd.replicas: /v1/health above one replica, /v1/ready at one (apache/hugegraph#3222)"
+ },
+ "readinessPath": {
+ "type": "string",
+ "pattern": "^/",
+ "description": "HTTP path for the PD readinessProbe; /v1/ready (default) on PD images that serve it, /v1/health on older ones"
+ }
+ }
+ },
+ "store": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "replicas",
+ "image",
+ "ports",
+ "dataPath",
+ "storage",
+ "resources",
+ "antiAffinity",
+ "pdb",
+ "waitImage",
+ "probes"
+ ],
+ "properties": {
+ "replicas": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 99
+ },
+ "image": {
+ "$ref": "#/definitions/image"
+ },
+ "javaOpts": {
+ "type": "string"
+ },
+ "ports": {
+ "$ref": "#/definitions/ports"
+ },
+ "dataPath": {
+ "type": "string",
+ "minLength": 1
+ },
+ "storage": {
+ "$ref": "#/definitions/storage"
+ },
+ "resources": {
+ "$ref": "#/definitions/resources"
+ },
+ "antiAffinity": {
+ "type": "string",
+ "enum": [
+ "required",
+ "preferred",
+ "disabled"
+ ]
+ },
+ "pdb": {
+ "$ref": "#/definitions/pdb"
+ },
+ "waitImage": {
+ "type": "string"
+ },
+ "waitResources": {
+ "$ref": "#/definitions/resources"
+ },
+ "probes": {
+ "$ref": "#/definitions/probes"
+ },
+ "podSecurityContext": {
+ "type": "object"
+ },
+ "securityContext": {
+ "type": "object"
+ },
+ "waitTimeoutSeconds": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "updateStrategy": {
+ "$ref": "#/definitions/updateStrategy"
+ },
+ "persistentVolumeClaimRetentionPolicy": {
+ "$ref": "#/definitions/persistentVolumeClaimRetentionPolicy"
+ },
+ "nodeSelector": {
+ "type": "object"
+ },
+ "tolerations": {
+ "type": "array"
+ },
+ "affinity": {
+ "type": "object"
+ },
+ "topologySpreadConstraints": {
+ "type": "array"
+ },
+ "priorityClassName": {
+ "type": "string"
+ },
+ "podAnnotations": {
+ "type": "object"
+ },
+ "podLabels": {
+ "type": "object"
+ },
+ "extraEnv": {
+ "type": "array"
+ },
+ "terminationGracePeriodSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "serviceAccount": {
+ "type": "object",
+ "properties": {
+ "create": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "automountServiceAccountToken": {
+ "type": "boolean"
+ }
+ }
+ },
+ "waitPath": {
+ "type": "string",
+ "pattern": "^/",
+ "description": "HTTP path the Store init container polls on each PD peer; /v1/ready (default) on PD images that serve it, /v1/health on older ones"
+ }
+ }
+ },
+ "server": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "replicas",
+ "image",
+ "port",
+ "backend",
+ "resources",
+ "waitImage",
+ "initStoreEnabled",
+ "auth",
+ "ingress",
+ "hpa",
+ "probes"
+ ],
+ "properties": {
+ "serviceId": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^[ -~]+$",
+ "description": "Printable ASCII PD service name (bytes 0x20-0x7E), including spaces and backslashes. DEFAULT preserves the image config."
+ },
+ "graphSpace": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^(DEFAULT|[a-z][a-z0-9_]{0,47})$",
+ "description": "Existing PD graph space: DEFAULT or up to 48 lowercase letters, digits and underscores, starting with a letter. DEFAULT preserves the image config."
+ },
+ "testGraphAlias": {
+ "type": "string",
+ "pattern": "^(DEFAULT|[a-z][a-z0-9_]{0,47})-[A-Za-z][A-Za-z0-9_]{0,47}$",
+ "description": "Existing Gremlin graph binding checked by helm test, as -. Choose the target graph explicitly; server.graphSpace does not create or infer this binding."
+ },
+ "replicas": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "image": {
+ "$ref": "#/definitions/image"
+ },
+ "javaOpts": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ },
+ "backend": {
+ "type": "string",
+ "const": "hstore"
+ },
+ "resources": {
+ "$ref": "#/definitions/resources"
+ },
+ "waitImage": {
+ "type": "string",
+ "description": "Image used by the Helm test hook"
+ },
+ "waitResources": {
+ "$ref": "#/definitions/resources"
+ },
+ "initStoreEnabled": {
+ "type": "boolean",
+ "const": false
+ },
+ "auth": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "enabled",
+ "admin",
+ "token"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "admin": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "password",
+ "existingSecret",
+ "key",
+ "autoGenerate"
+ ],
+ "properties": {
+ "password": {
+ "type": "string",
+ "description": "Inline admin password. Empty defers to existingSecret or autoGenerate. Printable ASCII with no space, no backslash and no colon, the same contract the Server wrapper enforces on an existingSecret: the Server decodes Basic-auth credentials as ASCII and splits them on every colon, so a non-ASCII password would be applied to the admin account and then answer 401 on every request, and a password with a colon would answer 400; the image entrypoint writes each space into rest-server.properties as a backslash-escaped space, which the Server keeps, and Commons Configuration trims a properties value and unescapes backslashes, so a space or a backslash would make the stored password and the effective one differ."
+ },
+ "existingSecret": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1
+ },
+ "autoGenerate": {
+ "type": "boolean"
+ }
+ }
+ },
+ "token": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "value",
+ "existingSecret",
+ "key",
+ "autoGenerate"
+ ],
+ "properties": {
+ "value": {
+ "type": "string",
+ "description": "JWT signing key. Either empty, which defers to existingSecret or autoGenerate, or at least 32 characters: the Server entrypoint rejects a shorter key and the pods CrashLoop."
+ },
+ "existingSecret": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1
+ },
+ "autoGenerate": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "enabled": {
+ "const": true
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "admin": {
+ "anyOf": [
+ {
+ "properties": {
+ "existingSecret": {
+ "minLength": 1
+ }
+ }
+ },
+ {
+ "properties": {
+ "password": {
+ "minLength": 1
+ },
+ "existingSecret": {
+ "const": ""
+ }
+ }
+ },
+ {
+ "properties": {
+ "autoGenerate": {
+ "const": true
+ },
+ "existingSecret": {
+ "const": ""
+ }
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "existingSecret": {
+ "const": ""
+ }
+ },
+ "required": [
+ "existingSecret"
+ ]
+ },
+ "then": {
+ "properties": {
+ "password": {
+ "pattern": "^[\\x21-\\x39\\x3b-\\x5b\\x5d-\\x7e]*$"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "token": {
+ "anyOf": [
+ {
+ "properties": {
+ "existingSecret": {
+ "minLength": 1
+ }
+ }
+ },
+ {
+ "properties": {
+ "value": {
+ "minLength": 1
+ },
+ "existingSecret": {
+ "const": ""
+ }
+ }
+ },
+ {
+ "properties": {
+ "autoGenerate": {
+ "const": true
+ },
+ "existingSecret": {
+ "const": ""
+ }
+ }
+ }
+ ],
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "existingSecret": {
+ "const": ""
+ }
+ },
+ "required": [
+ "existingSecret"
+ ]
+ },
+ "then": {
+ "properties": {
+ "value": {
+ "anyOf": [
+ {
+ "maxLength": 0
+ },
+ {
+ "minLength": 32
+ }
+ ]
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ ]
+ },
+ "ingress": {
+ "$ref": "#/definitions/serverIngress"
+ },
+ "hpa": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "enabled",
+ "minReplicas",
+ "maxReplicas",
+ "targetCPUUtilizationPercentage"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "minReplicas": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "maxReplicas": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "targetCPUUtilizationPercentage": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "readinessPath": {
+ "type": "string",
+ "pattern": "^/",
+ "description": "HTTP path for the Server readinessProbe; /readiness (apache/hugegraph#3212) on Server images that serve it, /versions (default) on older ones"
+ },
+ "probes": {
+ "$ref": "#/definitions/serverProbes"
+ },
+ "antiAffinity": {
+ "type": "string",
+ "enum": [
+ "required",
+ "preferred",
+ "disabled"
+ ]
+ },
+ "podSecurityContext": {
+ "type": "object"
+ },
+ "securityContext": {
+ "type": "object"
+ },
+ "pdb": {
+ "$ref": "#/definitions/pdb"
+ },
+ "nodeSelector": {
+ "type": "object"
+ },
+ "tolerations": {
+ "type": "array"
+ },
+ "affinity": {
+ "type": "object"
+ },
+ "topologySpreadConstraints": {
+ "type": "array"
+ },
+ "priorityClassName": {
+ "type": "string"
+ },
+ "podAnnotations": {
+ "type": "object"
+ },
+ "podLabels": {
+ "type": "object"
+ },
+ "extraEnv": {
+ "type": "array"
+ },
+ "terminationGracePeriodSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "serviceAccount": {
+ "type": "object",
+ "properties": {
+ "create": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "automountServiceAccountToken": {
+ "type": "boolean"
+ }
+ }
+ },
+ "testResources": {
+ "$ref": "#/definitions/resources"
+ },
+ "testGremlin": {
+ "type": "string",
+ "description": "Optional Gremlin assertions run with the hook's Server credential on each Pod. Supply trusted read-only scripts; read-only execution is not enforced."
+ },
+ "service": {
+ "$ref": "#/definitions/service"
+ },
+ "advertiseUrl": {
+ "type": "string",
+ "description": "URL registered with PD via server.urls_to_pd. Empty uses the in-cluster Server Service URL. Set to an externally reachable URL for outside Hubble PD discovery."
+ },
+ "storageWaitTimeoutSeconds": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 86400
+ }
+ }
+ },
+ "service": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "ClusterIP",
+ "NodePort",
+ "LoadBalancer",
+ null
+ ]
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "allowInsecureExposure": {
+ "type": "boolean",
+ "description": "Legacy compatibility field; does not gate Service exposure."
+ },
+ "nodePort": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0,
+ "maximum": 65535
+ },
+ "externalTrafficPolicy": {
+ "type": "string",
+ "enum": [
+ "Cluster",
+ "Local"
+ ]
+ },
+ "loadBalancerClass": {
+ "type": "string"
+ },
+ "loadBalancerIP": {
+ "type": "string"
+ },
+ "loadBalancerSourceRanges": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "externalIPs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "allocateLoadBalancerNodePorts": {
+ "type": "boolean"
+ }
+ }
+ },
+ "ingress": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "enabled",
+ "className",
+ "hosts",
+ "tls"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "className": {
+ "type": "string"
+ },
+ "hosts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "paths"
+ ],
+ "properties": {
+ "host": {
+ "type": "string"
+ },
+ "paths": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "path",
+ "pathType"
+ ],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "pathType": {
+ "type": "string",
+ "enum": [
+ "Exact",
+ "Prefix",
+ "ImplementationSpecific"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "tls": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "allowPlainHttp": {
+ "type": "boolean",
+ "description": "Legacy compatibility field; HTTP Ingress needs no acknowledgement."
+ }
+ }
+ },
+ "serverIngress": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "enabled",
+ "className",
+ "hosts",
+ "tls"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "className": {
+ "type": "string"
+ },
+ "hosts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "paths"
+ ],
+ "properties": {
+ "host": {
+ "type": "string"
+ },
+ "paths": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "path",
+ "pathType"
+ ],
+ "properties": {
+ "path": {
+ "type": "string"
+ },
+ "pathType": {
+ "type": "string",
+ "enum": [
+ "Exact",
+ "Prefix",
+ "ImplementationSpecific"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "tls": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "allowPlainHttp": {
+ "type": "boolean",
+ "description": "Legacy compatibility field; HTTP Ingress needs no acknowledgement."
+ }
+ }
+ },
+ "hubble": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "enabled",
+ "mode",
+ "image",
+ "port",
+ "persistence",
+ "resources",
+ "service",
+ "ingress",
+ "probes"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "mode": {
+ "type": "string",
+ "enum": [
+ "pd",
+ "direct"
+ ]
+ },
+ "allowWithoutServerAuth": {
+ "type": "boolean"
+ },
+ "image": {
+ "$ref": "#/definitions/image"
+ },
+ "port": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 65535
+ },
+ "persistence": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "size",
+ "storageClassName"
+ ],
+ "properties": {
+ "enabled": {
+ "type": [
+ "boolean",
+ "null"
+ ]
+ },
+ "size": {
+ "type": "string",
+ "minLength": 1
+ },
+ "storageClassName": {
+ "type": "string"
+ }
+ }
+ },
+ "resources": {
+ "$ref": "#/definitions/resources"
+ },
+ "podSecurityContext": {
+ "type": "object"
+ },
+ "securityContext": {
+ "type": "object"
+ },
+ "nodeSelector": {
+ "type": "object"
+ },
+ "tolerations": {
+ "type": "array"
+ },
+ "affinity": {
+ "type": "object"
+ },
+ "topologySpreadConstraints": {
+ "type": "array"
+ },
+ "priorityClassName": {
+ "type": "string"
+ },
+ "podAnnotations": {
+ "type": "object"
+ },
+ "podLabels": {
+ "type": "object"
+ },
+ "extraEnv": {
+ "type": "array"
+ },
+ "terminationGracePeriodSeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "serviceAccount": {
+ "type": "object",
+ "properties": {
+ "create": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "annotations": {
+ "type": "object"
+ },
+ "automountServiceAccountToken": {
+ "type": "boolean"
+ }
+ }
+ },
+ "service": {
+ "$ref": "#/definitions/service"
+ },
+ "ingress": {
+ "$ref": "#/definitions/ingress"
+ },
+ "probes": {
+ "$ref": "#/definitions/probes"
+ }
+ }
+ },
+ "serverStartupProbe": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "periodSeconds"
+ ],
+ "properties": {
+ "periodSeconds": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "failureThreshold": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1
+ },
+ "timeoutSeconds": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "initialDelaySeconds": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "successThreshold": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "serverProbes": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "startup",
+ "readiness",
+ "liveness"
+ ],
+ "properties": {
+ "startup": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/serverStartupProbe"
+ },
+ {
+ "properties": {
+ "successThreshold": {
+ "maximum": 1
+ }
+ }
+ }
+ ]
+ },
+ "readiness": {
+ "$ref": "#/definitions/probe"
+ },
+ "liveness": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/probe"
+ },
+ {
+ "properties": {
+ "successThreshold": {
+ "maximum": 1
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/helm/hugegraph/values.yaml b/helm/hugegraph/values.yaml
new file mode 100644
index 0000000000..c81a9ff5c8
--- /dev/null
+++ b/helm/hugegraph/values.yaml
@@ -0,0 +1,561 @@
+#
+# 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.
+#
+
+# Default values for HugeGraph HStore Helm chart.
+# See values-single.yaml and values-cluster.yaml for presets.
+
+# easy: browser/API NodePorts; strict: internal Services and NetworkPolicy.
+# Explicit component parameters always override these mode defaults.
+mode: easy
+global:
+ imageRegistry: ""
+persistence:
+ enabled: true
+
+fullnameOverride: ""
+nameOverride: ""
+
+imagePullSecrets: []
+
+pd:
+ replicas: 3
+ image:
+ repository: hugegraph/pd
+ # TODO: leave tag empty to inherit Chart.appVersion after the 1.8.0
+ # release images are published. Pin a tag or digest for production.
+ tag: latest
+ # Optional immutable digest, for example sha256:abc... When set it wins over
+ # tag and the image is pulled by digest, which is what a release gate should
+ # assert instead of a mutable tag.
+ digest: ""
+ pullPolicy: Always
+ # Empty preserves the image entrypoint's automatic JVM sizing. The chart
+ # renders its partition settings below as -D system properties ahead of
+ # this value, so flags placed here win on conflict.
+ javaOpts: ""
+ # Partition sharding, rendered as -D system properties that outrank the
+ # image's conf/application.yml. PD copies both values into its own
+ # persisted metadata at first bootstrap only; from then on the stored
+ # values are authoritative, so changing these later (or scaling
+ # store.replicas across the derivation boundary) has no effect on an
+ # initialized cluster. Post-bootstrap changes go through PD's config API
+ # and take effect on existing shard groups only when a partition patrol
+ # is triggered; see the README's Partition Sharding section.
+ partition:
+ # Shard replicas per partition, seeding PD at first bootstrap only.
+ # Empty derives 3 when store.replicas is at least 3, else 1, so a fresh
+ # multi-store install gets store-level HA instead of the image default
+ # of 1. The derivation skips 2 because PD clamps a shard count of 2 to
+ # 1 (two shards cannot elect a leader). An explicit value must be odd
+ # and must not exceed store.replicas.
+ defaultShardCount: ""
+ # Maximum shards per store, seeding PD at first bootstrap only. Also
+ # fixes the initial partition count: store.replicas x this value /
+ # shard count. The derived shard count of 3 gives the default topology
+ # 12 partitions instead of 36; raise this value to compensate when more
+ # partitions are wanted. Empty preserves the image default of 12.
+ storeMaxShardCount: ""
+ ports:
+ grpc: 8686
+ rest: 8620
+ raft: 8610
+ dataPath: /hugegraph-pd/pd_data
+ # PD REST Basic-auth credential. PD compares the password of every /v1
+ # request against auth.secret-key and refuses to start without one
+ # (HG_PD_AUTH_SECRET_KEY). The chart hands the same value to the Server
+ # storage wait (PD_AUTH_PASSWORD, user store) and to Hubble
+ # (operations.pd.password, user hubble). The Secret is kept on uninstall
+ # like the Server auth Secrets. The value must be printable ASCII with
+ # no leading whitespace and no backslashes: it is written into Hubble's
+ # Java properties file, which strips leading whitespace, unescapes
+ # backslashes, and reads the file as ISO-8859-1. Priority:
+ # existingSecret > value > autoGenerate.
+ auth:
+ # Optional plaintext secret (prefer existingSecret in shared clusters).
+ value: ""
+ # Pre-created Secret name. Must contain the key below; chart does not manage it.
+ existingSecret: ""
+ key: secret-key
+ # When existingSecret and value are empty, create a kept release-pd-auth Secret.
+ autoGenerate: true
+ storage:
+ # Unset inherits persistence.enabled; false uses temporary emptyDir.
+ enabled:
+ size: 10Gi
+ storageClassName: ""
+ resources: {}
+ # Pod-level and container-level securityContext. Empty by default because the
+ # published images run as root; set these to satisfy a restricted namespace.
+ podSecurityContext: {}
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ seccompProfile:
+ type: RuntimeDefault
+ # required | preferred | disabled. "preferred" schedules on clusters with
+ # fewer nodes than replicas; production should use "required" (see
+ # values-cluster.yaml) so one node failure cannot take out PD quorum.
+ antiAffinity: preferred
+ # Scheduling. `affinity` takes precedence over the antiAffinity preset.
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
+ topologySpreadConstraints: []
+ priorityClassName: ""
+ podAnnotations: {}
+ podLabels: {}
+ # Extra environment variables appended to the pd container.
+ extraEnv: []
+ # Gives a JVM with an on-disk store time to shut down cleanly on drain.
+ terminationGracePeriodSeconds: 300
+ serviceAccount:
+ create: true
+ name: ""
+ annotations: {}
+ # This chart makes no Kubernetes API calls, so no token is mounted.
+ automountServiceAccountToken: false
+ # Client Service for PD REST/gRPC. Defaults to ClusterIP for in-cluster
+ # consumers (Server, Store, in-chart Hubble). Set type to NodePort or
+ # LoadBalancer when an outside Hubble must reach PD for discovery.
+ service:
+ type: ClusterIP
+ annotations: {}
+ # Legacy acknowledgement, accepted for compatibility; no render gate.
+ allowInsecureExposure: false
+ # Optional fixed NodePorts; require service.type NodePort or LoadBalancer.
+ restNodePort:
+ grpcNodePort:
+ pdb:
+ enabled: true
+ minAvailable: 2
+ # PD's raft IP whitelist resolves peers once at boot, which under
+ # Kubernetes blocks peers whose pod IPs were not yet published or change
+ # later. Off by default in-cluster (upstream switch
+ # raft.ip-whitelist.enabled); k8s network policy/auth owns that layer.
+ # Set true to restore the image default.
+ raftIpWhitelistEnabled: false
+ # Raft RPC timeout in milliseconds, rendered as -Draft.rpc-timeout. It
+ # bounds raft requests between PDs. On images that wait for a raft
+ # ReadIndex before they read an id counter it is also the deadline of that
+ # wait: id allocation fails after this long while the leader cannot
+ # confirm a read, for example while a new leader commits its first entry.
+ # Images that have raft.rpc-connect-timeout (default 1000) use that option
+ # for the Ping that opens a connection to a peer; set it through
+ # pd.javaOpts, for example -Draft.rpc-connect-timeout=1000. It does not
+ # bound requests on a connection that is already open. On older images
+ # this timeout is also the timeout of that Ping: their default of 10000
+ # leaves the cluster leaderless for about a minute where 3000 elects a new
+ # leader in seconds. Empty preserves the image default.
+ raftRpcTimeoutMs: 3000
+ # Explicit rollout strategy instead of the implicit StatefulSet default.
+ updateStrategy:
+ type: RollingUpdate
+ # Keep graph metadata PVCs across delete and scale-down; data outlives the
+ # workload by default. Set to Delete for disposable environments. Honored
+ # on Kubernetes >=1.27 (or with the StatefulSetAutoDeletePVC feature gate);
+ # older API servers drop the field, which matches Retain behavior anyway.
+ persistentVolumeClaimRetentionPolicy:
+ whenDeleted: Retain
+ whenScaled: Retain
+ # Startup can take a while during Raft bootstrap
+ # HTTP path the PD readinessProbe hits. /v1/ready answers 503 while there
+ # is no raft leader, so a PD outside the quorum stops passing readiness.
+ # Startup and liveness stay on /v1/health so a PD that merely lost its
+ # leader is not restarted.
+ readinessPath: /v1/ready
+ # HTTP path the PD startup and liveness probes hit. Empty derives it from
+ # the replica count: /v1/health with more than one PD, so a normal election
+ # never restarts a follower, and /v1/ready with a single PD, which has no
+ # election to lose. Older images otherwise pass /v1/health forever after
+ # losing leadership for good (apache/hugegraph#3222). Startup follows liveness so
+ # the boot-to-ready window sits inside the startup budget.
+ # TODO: drop the single-PD readiness derivation once the minimum supported image
+ # includes the /v1/health fatal Raft error fix for apache/hugegraph#3222.
+ livenessPath: ""
+ probes:
+ startup:
+ failureThreshold: 30
+ periodSeconds: 10
+ timeoutSeconds: 5
+ readiness:
+ periodSeconds: 10
+ failureThreshold: 3
+ timeoutSeconds: 5
+ liveness:
+ periodSeconds: 20
+ failureThreshold: 3
+ timeoutSeconds: 5
+
+store:
+ replicas: 3
+ image:
+ repository: hugegraph/store
+ # TODO: leave tag empty to inherit Chart.appVersion after the 1.8.0
+ # release images are published. Pin a tag or digest for production.
+ tag: latest
+ # Optional immutable digest, for example sha256:abc... When set it wins over
+ # tag and the image is pulled by digest, which is what a release gate should
+ # assert instead of a mutable tag.
+ digest: ""
+ pullPolicy: Always
+ # Empty preserves the image entrypoint's automatic JVM sizing.
+ javaOpts: ""
+ ports:
+ grpc: 8500
+ raft: 8510
+ rest: 8520
+ dataPath: /hugegraph-store/storage
+ storage:
+ # Unset inherits persistence.enabled; false uses temporary emptyDir.
+ enabled:
+ size: 50Gi
+ storageClassName: ""
+ resources: {}
+ podSecurityContext: {}
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ seccompProfile:
+ type: RuntimeDefault
+ # required | preferred | disabled. "preferred" schedules on clusters with
+ # fewer nodes than replicas; production should use "required" (see
+ # values-cluster.yaml) so one node failure cannot co-locate shard replicas.
+ antiAffinity: preferred
+ # Scheduling. `affinity` takes precedence over the antiAffinity preset.
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
+ topologySpreadConstraints: []
+ priorityClassName: ""
+ podAnnotations: {}
+ podLabels: {}
+ # Extra environment variables appended to the store container.
+ extraEnv: []
+ # Gives a JVM with an on-disk store time to shut down cleanly on drain.
+ terminationGracePeriodSeconds: 300
+ serviceAccount:
+ create: true
+ name: ""
+ annotations: {}
+ # This chart makes no Kubernetes API calls, so no token is mounted.
+ automountServiceAccountToken: false
+ pdb:
+ enabled: true
+ minAvailable: 2
+ # Optional tool image override; empty reuses the Store image.
+ waitImage: ""
+ # HTTP path the init container polls on every PD peer; a majority must
+ # answer 2xx before the Store starts. /v1/ready makes that majority a
+ # raft quorum instead of a set of live listeners.
+ waitPath: /v1/ready
+ # Bound the PD wait so a cluster whose PDs never come up fails visibly
+ # instead of sitting in Init:0/1 forever.
+ waitTimeoutSeconds: 900
+ # Optional bounds for the PD wait init container.
+ waitResources: {}
+ # Explicit rollout strategy instead of the implicit StatefulSet default.
+ updateStrategy:
+ type: RollingUpdate
+ # Keep graph data PVCs across delete and scale-down; data outlives the
+ # workload by default. Set to Delete for disposable environments.
+ persistentVolumeClaimRetentionPolicy:
+ whenDeleted: Retain
+ whenScaled: Retain
+ probes:
+ startup:
+ failureThreshold: 40
+ periodSeconds: 10
+ timeoutSeconds: 5
+ readiness:
+ periodSeconds: 10
+ failureThreshold: 3
+ timeoutSeconds: 5
+ liveness:
+ periodSeconds: 20
+ failureThreshold: 3
+ timeoutSeconds: 5
+
+server:
+ replicas: 3
+ # DEFAULT preserves the image config; non-default values select PD service metadata.
+ # serviceId accepts printable ASCII, including spaces and backslashes.
+ # Create a non-default graph space before starting a Server assigned to it.
+ serviceId: DEFAULT
+ graphSpace: DEFAULT
+ image:
+ repository: hugegraph/server
+ # TODO: leave tag empty to inherit Chart.appVersion after the 1.8.0
+ # release images are published. Pin a tag or digest for production.
+ tag: latest
+ # Optional immutable digest, for example sha256:abc... When set it wins over
+ # tag and the image is pulled by digest, which is what a release gate should
+ # assert instead of a mutable tag.
+ digest: ""
+ pullPolicy: Always
+ # Empty preserves the image entrypoint's automatic JVM sizing.
+ javaOpts: ""
+ port: 8080
+ # HTTP path the Server readinessProbe hits. /readiness (apache/hugegraph#3212)
+ # answers 503 while the Server cannot serve graph traffic, so such a Server
+ # drops out of the Service instead of answering 500 to every graph request.
+ # Keep /versions on Server images that do not serve it. Startup and liveness
+ # stay on /versions so a Server that merely lost its storage is not restarted.
+ readinessPath: /versions
+ backend: hstore
+ resources: {}
+ # Server is stateless and may scale past the node count via HPA, so the
+ # default only prefers spreading. Use "required" when replicas are always
+ # fewer than schedulable nodes.
+ # required | preferred | disabled
+ antiAffinity: preferred
+ # Scheduling. `affinity` takes precedence over the antiAffinity preset.
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
+ topologySpreadConstraints: []
+ priorityClassName: ""
+ podAnnotations: {}
+ podLabels: {}
+ # Extra environment variables appended to the server container.
+ extraEnv: []
+ # Allows in-flight requests to drain before the Server is stopped.
+ terminationGracePeriodSeconds: 60
+ serviceAccount:
+ create: true
+ name: ""
+ annotations: {}
+ # This chart makes no Kubernetes API calls, so no token is mounted.
+ automountServiceAccountToken: false
+ podSecurityContext: {}
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ seccompProfile:
+ type: RuntimeDefault
+ # Server has no quorum to preserve, so no PodDisruptionBudget by default.
+ pdb:
+ enabled: false
+ minAvailable: 2
+ # Optional Helm test image override; empty reuses the Server image.
+ waitImage: ""
+ # Existing Gremlin graph binding checked on every Server Pod. Select the
+ # target graph explicitly; graphSpace does not create or infer this binding.
+ testGraphAlias: DEFAULT-hugegraph
+ # Optional Gremlin assertions, run with the hook's Server credential on every Pod.
+ # Supply trusted read-only scripts; read-only execution is not enforced.
+ # Use --set-file server.testGremlin=/path/to/assertions.groovy; assert expected results.
+ testGremlin: ""
+ # Resources for the Helm test hook container. Bounded by default so the hook
+ # cannot run unlimited on a restricted or quota-managed namespace.
+ testResources:
+ requests:
+ cpu: 25m
+ memory: 32Mi
+ limits:
+ cpu: 250m
+ memory: 64Mi
+ # Distributed HStore: the init-store gate must be explicitly false, so that
+ # concurrent Server replicas never initialize the same backend. No
+ # HG_SERVER_SKIP_INIT and no init Job.
+ initStoreEnabled: false
+ # Maximum storage wait; startup probe defaults adapt to this value.
+ storageWaitTimeoutSeconds: 300
+ auth:
+ # On by default: a chart-managed release-admin Secret supplies the password
+ # unless admin.existingSecret or admin.password is set. Set enabled=false
+ # only for trusted networks.
+ enabled: true
+ # Admin password: inline value vs Kubernetes Secret name are separate keys.
+ # Priority: existingSecret > password > autoGenerate.
+ admin:
+ # Optional plaintext password (prefer existingSecret in shared clusters).
+ password: ""
+ # Pre-created Secret name. Must contain key below; chart does not manage it.
+ existingSecret: ""
+ key: password
+ # When existingSecret and password are empty, create a kept release-admin Secret.
+ autoGenerate: true
+ # JWT signing key for auth.token_secret / HG_SERVER_AUTH_TOKEN_SECRET.
+ # Must be identical on every Server replica or Hubble login fails behind
+ # the Service. Priority: existingSecret > value > autoGenerate.
+ token:
+ # Optional plaintext signing key (prefer existingSecret in shared clusters).
+ value: ""
+ # Pre-created Secret name. Chart does not manage it.
+ existingSecret: ""
+ key: token_secret
+ # When existingSecret and value are empty, create a kept release-auth-token Secret.
+ autoGenerate: true
+ # URL announced to PD via server.urls_to_pd for discovery clients such as Hubble.
+ # Empty announces each Server Pod IP so in-cluster clients retain the full replica list.
+ # Set this to a URL reachable from outside the cluster (NodePort, LoadBalancer, or Ingress) when an external Hubble uses PD mode; all replicas then announce that shared logical endpoint.
+ advertiseUrl: ""
+ service:
+ # Unset inherits mode (easy: NodePort, strict: ClusterIP).
+ type:
+ annotations: {}
+ # Legacy acknowledgement, accepted for compatibility; no render gate.
+ allowInsecureExposure: false
+ ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ hosts:
+ - host: hugegraph.local
+ paths:
+ - path: /
+ pathType: Prefix
+ tls: []
+ hpa:
+ enabled: false
+ minReplicas: 3
+ maxReplicas: 10
+ targetCPUUtilizationPercentage: 70
+ probes:
+ startup:
+ # A first probe can run immediately; (failureThreshold - 1) * periodSeconds
+ # must cover the storage wait plus at least 120 seconds for Server startup.
+ # Unset derives a budget from storageWaitTimeoutSeconds.
+ failureThreshold:
+ periodSeconds: 5
+ timeoutSeconds: 5
+ readiness:
+ periodSeconds: 10
+ failureThreshold: 3
+ timeoutSeconds: 5
+ liveness:
+ periodSeconds: 20
+ failureThreshold: 3
+ timeoutSeconds: 5
+
+# Hubble UI is enabled by default; disable with hubble.enabled=false.
+hubble:
+ enabled: true
+ # pd: discover the cluster through PD (enables the cluster operations view).
+ # direct: talk to the Server client Service only, without PD discovery.
+ # Current Hubble images require server.auth to be enabled: the UI login
+ # authenticates against the cluster, so rendering fails otherwise unless
+ # allowWithoutServerAuth explicitly overrides for images that support it.
+ mode: pd
+ allowWithoutServerAuth: false
+ image:
+ repository: hugegraph/hubble
+ # Tracks latest until the next HugeGraph release tag is published. Pin the
+ # tag (or digest) and switch to IfNotPresent for production.
+ tag: latest
+ # Optional immutable digest, for example sha256:abc... When set it wins over
+ # tag and the image is pulled by digest, which is what a release gate should
+ # assert instead of a mutable tag.
+ digest: ""
+ pullPolicy: Always
+ port: 8088
+ # Keeps Hubble's per-instance H2 metadata across Pod replacement; see the
+ # README's Hubble section for the single-replica and H2 constraints.
+ # size and storageClassName apply at install time only; the PVC is kept
+ # on helm uninstall.
+ persistence:
+ # Unset inherits persistence.enabled.
+ enabled:
+ size: 1Gi
+ storageClassName: ""
+ resources: {}
+ # When persistence is enabled and the pod runs as non-root, set a matching
+ # fsGroup here so H2 can write /hubble-data.
+ podSecurityContext: {}
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ seccompProfile:
+ type: RuntimeDefault
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
+ topologySpreadConstraints: []
+ priorityClassName: ""
+ podAnnotations: {}
+ podLabels: {}
+ # Extra environment variables appended to the hubble container.
+ extraEnv: []
+ terminationGracePeriodSeconds: 30
+ serviceAccount:
+ create: true
+ name: ""
+ annotations: {}
+ # This chart makes no Kubernetes API calls, so no token is mounted.
+ automountServiceAccountToken: false
+ service:
+ # Unset inherits mode (easy: NodePort, strict: ClusterIP).
+ type:
+ annotations: {}
+ # Legacy acknowledgement, accepted for compatibility; no render gate.
+ allowInsecureExposure: false
+ ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ hosts:
+ - host: hubble.local
+ paths:
+ - path: /
+ pathType: Prefix
+ tls: []
+ # Legacy acknowledgement, accepted for compatibility.
+ allowPlainHttp: false
+ probes:
+ startup:
+ failureThreshold: 30
+ periodSeconds: 5
+ timeoutSeconds: 5
+ readiness:
+ periodSeconds: 10
+ failureThreshold: 3
+ timeoutSeconds: 5
+ liveness:
+ periodSeconds: 20
+ failureThreshold: 3
+ timeoutSeconds: 5
+
+# Kubernetes NetworkPolicy: mode default plus explicit per-component rules.
+# easy does not isolate clients; strict isolates release traffic and DNS. Only
+# enforced when the network plugin implements NetworkPolicy (kind v0.25+,
+# k3s, Calico, Cilium); elsewhere the objects are accepted and do nothing.
+# Nothing outside the release is admitted unless it is listed in
+# .extraIngress as standard NetworkPolicy ingress rules: apps in
+# other namespaces, Prometheus, Vermeer, and the Ingress controller. Exposing
+# a component does not automatically add callers: configure extraIngress
+# and extraEgress using native Kubernetes NetworkPolicy rules.
+networkPolicy:
+ # Unset inherits mode (easy: false, strict: true).
+ enabled:
+ pd:
+ extraIngress: []
+ store:
+ extraIngress: []
+ server:
+ extraIngress: []
+ hubble:
+ extraIngress: []
+ # For Hubble's optional outside endpoints (es.urls, prometheus.url set
+ # through extraEnv). PD, Store and Server reach only the release and DNS.
+ extraEgress: []
+
+# No init Job; see README.md for the HStore initialization contract.
+# Install with: helm install ... --wait (no --wait-for-jobs)
diff --git a/helm/test_helm_hook.py b/helm/test_helm_hook.py
new file mode 100644
index 0000000000..8c3a2d30f4
--- /dev/null
+++ b/helm/test_helm_hook.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Execute the rendered Helm hook with local command fixtures, without networking."""
+
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+import yaml
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CHART = ROOT / "helm" / "hugegraph"
+MOCK = r'''
+import json, os, pathlib, sys
+command = pathlib.Path(sys.argv[0]).name
+directory = pathlib.Path(os.environ["FIXTURE_DIR"])
+if command == "date":
+ counter = directory / "time"
+ value = int(counter.read_text()) + 100 if counter.exists() else 0
+ counter.write_text(str(value))
+ print(value)
+elif command == "getent":
+ for address in os.environ["FIXTURE_IPS"].split():
+ print(address, "STREAM server")
+elif command == "curl":
+ args = sys.argv[1:]
+ config = sys.stdin.read() if "--config" in args else ""
+ url = args[-1]
+ body = json.loads(args[args.index("-d") + 1]) if "-d" in args else None
+ with (directory / "requests.jsonl").open("a") as output:
+ output.write(json.dumps({"url": url, "body": body,
+ "args": args, "config": config}) + "\n")
+ if body is None:
+ print("{}")
+ else:
+ case = os.environ.get("FIXTURE_CASE", "healthy")
+ bad_pod = "10.0.0.2" in url
+ business = body["gremlin"] == os.environ.get("FIXTURE_BUSINESS")
+ failure = bad_pod and (case in ("binding", "invalid", "creating", "rebuilding") or
+ (case == "business" and business))
+ if failure:
+ message = {"binding": "Could not rebind graph",
+ "invalid": "Index by_age is INVALID (CREATED expected)",
+ "creating": "Index by_age is CREATING (CREATED expected)",
+ "rebuilding": "Index by_age is REBUILDING (CREATED expected)",
+ "business": "Assertion failed: expected index results"}[case]
+ print(json.dumps({"message": message}))
+ print("400" if case == "binding" else "500")
+ else:
+ print('{"result":{"data":[0]}}\n200')
+'''
+
+
+class HelmHookTest(unittest.TestCase):
+ def render(self, directory, legacy_values=False, **values):
+ chart = CHART
+ if legacy_values:
+ # --reuse-values can render with old defaults that predate this key.
+ chart = directory / "legacy-chart"
+ shutil.copytree(CHART, chart)
+ defaults_file = chart / "values.yaml"
+ defaults = yaml.safe_load(defaults_file.read_text())
+ defaults["server"].pop("testGraphAlias")
+ defaults_file.write_text(yaml.safe_dump(defaults))
+ configuration = directory / "values.yaml"
+ configuration.write_text(yaml.safe_dump(values))
+ result = subprocess.run(
+ ["helm", "template", "fixture", str(chart), "-f", str(configuration),
+ "--show-only", "templates/tests/test-connection.yaml"],
+ capture_output=True, text=True)
+ self.assertEqual(0, result.returncode, result.stderr)
+ return yaml.safe_load(result.stdout)["spec"]["containers"][0]["command"][2]
+
+ def run_hook(self, case="healthy", addresses=None, legacy_values=False, **values):
+ temporary = tempfile.TemporaryDirectory(prefix="helm-hook-")
+ self.addCleanup(temporary.cleanup)
+ directory = Path(temporary.name)
+ script = self.render(directory, legacy_values=legacy_values, **values)
+ commands = directory / "bin"
+ commands.mkdir()
+ for name in ("curl", "date", "getent", "sleep"):
+ executable = commands / name
+ executable.write_text(f"#!{sys.executable}\n" + MOCK)
+ executable.chmod(0o755)
+ business = values.get("server", {}).get("testGremlin", "")
+ environment = dict(os.environ, PATH=f"{commands}:{os.environ['PATH']}",
+ FIXTURE_DIR=str(directory), FIXTURE_CASE=case,
+ FIXTURE_BUSINESS=business, PASSWORD="fixture-secret",
+ HG_TEST_DEADLINE_S="1", FIXTURE_IPS=addresses or
+ "10.0.0.1 10.0.0.2 10.0.0.3")
+ result = subprocess.run(["sh", "-c", script], env=environment, cwd=directory,
+ capture_output=True, text=True, timeout=15)
+ requests = [json.loads(line) for line in
+ (directory / "requests.jsonl").read_text().splitlines()]
+ return result, requests, directory
+
+ def test_every_pod_is_checked_with_auth(self):
+ result, requests, _ = self.run_hook()
+ self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertEqual(3, sum(request["body"] is not None for request in requests))
+ self.assertTrue(all("Authorization: Basic " in request["config"] for request in requests))
+ self.assertIn("business assertions skipped", result.stdout)
+
+ def test_failed_binding_or_unready_cached_index_reports_pod_and_stage(self):
+ for case, message in (("binding", "Could not rebind graph"),
+ ("invalid", "Index by_age is INVALID (CREATED expected)"),
+ ("creating", "Index by_age is CREATING (CREATED expected)"),
+ ("rebuilding", "Index by_age is REBUILDING (CREATED expected)")):
+ with self.subTest(case=case):
+ result, requests, _ = self.run_hook(case)
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn("Gremlin binding/index-state failed on http://10.0.0.2:8080/gremlin",
+ result.stdout)
+ self.assertIn(message, result.stdout)
+ self.assertEqual(3, sum(request["body"] is not None for request in requests))
+
+ def test_business_assertion_runs_on_every_pod_and_reports_failure(self):
+ business = "assert graph.traversal().V().count().next() == 7; true"
+ for case, expected in (("healthy", 0), ("business", 1)):
+ with self.subTest(case=case):
+ result, requests, _ = self.run_hook(case, server={"testGremlin": business})
+ self.assertEqual(expected, result.returncode, result.stdout + result.stderr)
+ self.assertEqual(3, sum(request["body"] is not None and
+ request["body"]["gremlin"] == business for request in requests))
+ if expected:
+ self.assertIn("business assertion failed on http://10.0.0.2:8080/gremlin",
+ result.stdout)
+ self.assertIn("Assertion failed: expected index results", result.stdout)
+
+ def test_selected_alias_applies_to_index_and_business_checks(self):
+ business = "assert graph.traversal().V().count().next() == 7; true"
+ result, requests, _ = self.run_hook(
+ server={"testGraphAlias": "team_a-demo", "testGremlin": business})
+ self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+ payloads = [request["body"] for request in requests if request["body"]]
+ self.assertEqual(6, len(payloads))
+ self.assertTrue(all(payload["aliases"] == {"graph": "team_a-demo"}
+ for payload in payloads))
+
+ def test_missing_or_null_alias_preserves_default_for_both_checks(self):
+ business = "assert graph.traversal().V().count().next() == 7; true"
+ for missing in (False, True):
+ with self.subTest(alias="missing" if missing else "null"):
+ server = {"testGremlin": business}
+ if not missing:
+ server["testGraphAlias"] = None
+ result, requests, _ = self.run_hook(legacy_values=missing, server=server)
+ self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+ payloads = [request["body"] for request in requests if request["body"]]
+ self.assertEqual(6, len(payloads))
+ self.assertEqual([{"graph": "DEFAULT-hugegraph"}] * 6,
+ [payload["aliases"] for payload in payloads])
+
+ def test_business_script_is_json_data_and_auth_can_be_disabled(self):
+ business = '''assert 'single' != "double"
+// `touch injected` $(touch injected) ' "
+true'''
+ result, requests, directory = self.run_hook(
+ server={"testGremlin": business, "auth": {"enabled": False}},
+ hubble={"enabled": False})
+ self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertFalse((directory / "injected").exists())
+ self.assertTrue(all(not request["config"] and "--config" not in request["args"]
+ for request in requests))
+ self.assertEqual(3, sum(request["body"] is not None and
+ request["body"]["gremlin"] == business for request in requests))
+
+ def test_dual_stack_does_not_count_pods_twice(self):
+ result, requests, _ = self.run_hook(
+ addresses="10.0.0.1 10.0.0.2 fd00::1 fd00::2")
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn("pods=2 floor=3", result.stderr)
+ self.assertEqual(2, sum(request["body"] is not None for request in requests))
+
+ def test_rendered_groovy_checks_cached_ids_without_refreshing_indexes(self):
+ self.assertTrue(os.environ.get("GROOVY_JAR"),
+ "Set GROOVY_JAR to the Server Groovy jar before running this suite")
+ _, requests, directory = self.run_hook()
+ payload = next(request["body"] for request in requests if request["body"])
+ script = directory / "smoke.groovy"
+ script.write_text(payload["gremlin"])
+ result = subprocess.run(
+ [shutil.which("java"), "-cp", os.environ["GROOVY_JAR"], "groovy.ui.GroovyMain",
+ str(ROOT / "helm" / "testdata" / "test_index_check.groovy"), str(script)],
+ capture_output=True, text=True, timeout=30)
+ self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertIn("cached index checks passed", result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/helm/test_helm_package.py b/helm/test_helm_package.py
new file mode 100644
index 0000000000..0fea178a78
--- /dev/null
+++ b/helm/test_helm_package.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Validate the artifact shipped to Helm, beyond source template tests."""
+
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CHART = ROOT / "helm" / "hugegraph"
+PACKAGE_BUDGET = 512 * 1024
+
+
+class HelmPackageTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ if shutil.which("helm") is None:
+ raise RuntimeError("Helm is required for the packaged chart regression")
+ temporary = tempfile.TemporaryDirectory(prefix="hugegraph-chart-")
+ cls.addClassCleanup(temporary.cleanup)
+ cls.directory = Path(temporary.name)
+ cls.version = re.search(r"(?m)^version: (.+)$",
+ (CHART / "Chart.yaml").read_text()).group(1)
+ subprocess.run(["helm", "package", str(CHART), "--destination", str(cls.directory)],
+ check=True, capture_output=True, text=True)
+ packages = list(cls.directory.glob("*.tgz"))
+ if len(packages) != 1:
+ raise AssertionError("Expected exactly one packaged chart")
+ cls.package = packages[0]
+ with tarfile.open(cls.package, "r:gz") as archive:
+ cls.files = {}
+ for member in archive.getmembers():
+ if member.isfile():
+ name = member.name.removeprefix("hugegraph-helm/")
+ cls.files[name] = archive.extractfile(member).read()
+
+ def test_distribution_identity(self):
+ self.assertEqual(f"hugegraph-helm-{self.version}.tgz", self.package.name)
+ self.assertRegex(self.files["Chart.yaml"].decode(),
+ r"(?m)^name: hugegraph-helm$")
+ rendered = subprocess.run(
+ ["helm", "template", "hugegraph", str(self.package)],
+ check=True, capture_output=True, text=True).stdout
+ self.assertIn("name: hugegraph-server\n", rendered)
+ self.assertIn('app.kubernetes.io/name: "hugegraph"', rendered)
+ self.assertIn(f"helm.sh/chart: hugegraph-helm-{self.version}", rendered)
+
+ def test_archive_budget(self):
+ self.assertLess(self.package.stat().st_size, PACKAGE_BUDGET,
+ "Chart exceeds its package budget; check non-runtime assets")
+
+ def test_no_contributor_or_binary_documentation_files(self):
+ excluded = ("images/", "tests/", "scripts/", "testdata/")
+ leaked = [name for name in self.files
+ if name.startswith(excluded) or name.endswith(".tgz")]
+ self.assertEqual([], leaked,
+ "Development assets must not enter the Helm release payload")
+
+ def test_required_configuration_is_preserved(self):
+ required = ("Chart.yaml", "README.md", "values.yaml", "values.schema.json",
+ "values-single.yaml", "values-cluster.yaml", "values-strict.yaml",
+ "docs/operations.md", "docs/distribution.md", "examples/README.md")
+ for name in required:
+ with self.subTest(file=name):
+ self.assertIn(name, self.files)
+ # Helm normalizes Chart.yaml and values.yaml while packaging.
+ if name not in ("Chart.yaml", "values.yaml"):
+ self.assertEqual((CHART / name).read_bytes(), self.files[name])
+
+ def test_every_runtime_template_is_preserved(self):
+ for source in (CHART / "templates").rglob("*"):
+ if source.is_file():
+ name = source.relative_to(CHART).as_posix()
+ with self.subTest(template=name):
+ self.assertIn(name, self.files)
+ self.assertEqual(source.read_bytes(), self.files[name])
+
+ def test_internal_inventory_covers_workloads_and_helpers(self):
+ example = (CHART / "examples" / "README.md").read_text()
+ inventory = example.split("## Internal images", 1)[1].split("`global.imageRegistry`", 1)[0]
+ images = set(re.findall(r"`(hugegraph/[^`]+:[^`]+)`", inventory))
+ self.assertEqual(4, len(images), "Document all four default component images")
+ registry = "registry.internal.example.com"
+ rendered = subprocess.run(
+ ["helm", "template", "inventory-check", str(self.package),
+ "--values", str(CHART / "examples" / "values-internal-registry.yaml")],
+ check=True, capture_output=True, text=True).stdout
+ selected = set(re.findall(r'^\s+image:\s*["\']?([^"\'\s]+)', rendered, re.MULTILINE))
+ self.assertEqual({f"{registry}/{image}" for image in images}, selected,
+ "Mirror inventory must cover workloads, init containers and test hooks")
+ for name in ("README.md", "examples/values-internal-registry.yaml"):
+ text = (CHART / name).read_text()
+ for image in images:
+ self.assertIn(image.split(":", 1)[0], text,
+ f"{name} omits a required image repository")
+
+ def test_packaged_chart_renders_each_preset(self):
+ for preset in (None, "values-single.yaml", "values-cluster.yaml", "values-strict.yaml"):
+ with self.subTest(preset=preset or "default"):
+ command = ["helm", "template", "package-check", str(self.package)]
+ if preset:
+ # Use the values shipped inside the archive, not the source tree.
+ values = self.directory / preset
+ values.write_bytes(self.files[preset])
+ command.extend(["--values", str(values)])
+ rendered = subprocess.run(command, check=True, capture_output=True)
+ self.assertTrue(b"kind: StatefulSet" in rendered.stdout,
+ "Packaged chart did not render a StatefulSet")
+ self.assertTrue(b"kind: Deployment" in rendered.stdout,
+ "Packaged chart did not render a Deployment")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/helm/testdata/test_index_check.groovy b/helm/testdata/test_index_check.groovy
new file mode 100644
index 0000000000..90257b7360
--- /dev/null
+++ b/helm/testdata/test_index_check.groovy
@@ -0,0 +1,60 @@
+// 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.
+
+// Execute the rendered script, with stale cached ID 11 but healthy metadata.
+class FixtureTraversal {
+ def V() { this }
+ def limit(count) { assert count == 1; this }
+ def count() { 0 }
+}
+
+class FixtureGraph {
+ Map statuses
+ List lookups = []
+ def schema() {
+ return [getVertexLabels: { -> statuses.containsKey(11) ? [[indexLabels: { -> [11] }]] : [] },
+ getEdgeLabels: { -> statuses.containsKey(22) ? [[indexLabels: { -> [22] }]] : [] },
+ getIndexLabels: { -> throw new AssertionError('metadata enumeration refreshes cache') }]
+ }
+ def indexLabel(id) {
+ assert id instanceof Integer : 'name lookup can refresh the cache'
+ lookups.add(id)
+ return [name: { -> "index_${id}" },
+ status: { -> [name: { -> statuses[id] }, ok: { -> statuses[id] == 'CREATED' }] }]
+ }
+ def traversal() {
+ return new FixtureTraversal()
+ }
+}
+
+def source = new File(args[0]).text
+[[11: 'CREATED', 22: 'CREATED'], [:]].each { statuses ->
+ def graph = new FixtureGraph(statuses: statuses)
+ assert new GroovyShell(new Binding(graph: graph)).evaluate(source) == 0
+ assert graph.lookups as Set == statuses.keySet()
+}
+['CREATING', 'REBUILDING', 'DELETING', 'UNDELETED', 'INVALID', 'CLEARING'].each { status ->
+ [11, 22].each { indexId ->
+ def graph = new FixtureGraph(statuses: [11: 'CREATED', 22: 'CREATED'])
+ graph.statuses[indexId] = status
+ try {
+ new GroovyShell(new Binding(graph: graph)).evaluate(source)
+ throw new AssertionError('non-CREATED cached index must fail')
+ } catch (IllegalStateException expected) {
+ assert expected.message == "Index index_${indexId} is ${status} (CREATED expected)"
+ }
+ }
+}
+println 'cached index checks passed'
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template b/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template
index 43979ec120..f838fd2a8a 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template
+++ b/hugegraph-cluster-test/hugegraph-clustertest-dist/src/assembly/static/conf/rest-server.properties.template
@@ -19,7 +19,7 @@
# could use '0.0.0.0' or specified (real)IP to expose external network access
restserver.url=http://$REST_SERVER_ADDRESS$
# gremlin server url, need to be consistent with host and port in gremlin-server.yaml
-#gremlinserver.url=http://$REST_SERVER_ADDRESS$
+gremlinserver.url=http://$GREMLIN_SERVER_ADDRESS$
graphs=./conf/graphs
@@ -39,6 +39,8 @@ arthas.disabled_commands=jad
auth.authenticator=org.apache.hugegraph.auth.StandardAuthenticator
# true if using hstore mode
usePD=true
+cluster=hg
+pd.peers=$PD_PEERS_LIST$
# default password
auth.admin_pa=pa
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/pom.xml b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/pom.xml
index b59648304f..e6b364651a 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/pom.xml
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/pom.xml
@@ -30,8 +30,6 @@
- 11
- 11UTF-8
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/ClusterConstant.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/ClusterConstant.java
index 730bbc53ed..e1bad5bf36 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/ClusterConstant.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/ClusterConstant.java
@@ -49,6 +49,7 @@ public class ClusterConstant {
public static final String GRAPH_TEMPLATE_FILE = "hugegraph.properties.template";
public static final String GREMLIN_DRIVER_SETTING_FILE = "gremlin-driver-settings.yaml";
public static final String GREMLIN_SERVER_FILE = "gremlin-server.yaml";
+ public static final String JVM_MODULE_OPTIONS_FILE = "jvm-module.options";
public static final String REMOTE_SETTING_FILE = "remote.yaml";
public static final String REMOTE_OBJECTS_SETTING_FILE = "remote-objects.yaml";
public static final String EMPTY_SAMPLE_GROOVY_FILE = "scripts/empty-sample.groovy";
@@ -106,7 +107,7 @@ public static String getFileInDir(String path, String fileName) {
return "";
}
- public static boolean isJava11OrHigher() {
+ public static boolean isJava17OrHigher() {
String version = System.getProperty("java.version");
if (version.startsWith("1.")) {
version = version.substring(2, 3);
@@ -117,7 +118,7 @@ public static boolean isJava11OrHigher() {
}
}
int versionNumber = Integer.parseInt(version);
- return versionNumber >= 11;
+ return versionNumber >= 17;
}
public static String getProjectDir() {
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/EnvUtil.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/EnvUtil.java
index 4d4bab3831..1c5f4c1357 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/EnvUtil.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/base/EnvUtil.java
@@ -18,7 +18,9 @@
package org.apache.hugegraph.ct.base;
import java.io.IOException;
+import java.net.InetSocketAddress;
import java.net.ServerSocket;
+import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -30,6 +32,7 @@
public class EnvUtil {
private static final Logger LOG = HGTestLogger.UTIL_LOG;
+ private static final int PORT_CONNECT_TIMEOUT_MILLIS = 500;
private static final Set ports = new HashSet<>();
public static int getAvailablePort() {
@@ -48,6 +51,16 @@ public static int getAvailablePort() {
}
}
+ public static boolean isPortOpen(String host, int port) {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(host, port),
+ PORT_CONNECT_TIMEOUT_MILLIS);
+ return true;
+ } catch (IOException ignored) {
+ return false;
+ }
+ }
+
public static void copyFileToDestination(Path source, Path destination) {
try {
ensureParentDirectoryExists(destination);
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ClusterConfig.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ClusterConfig.java
index c71e4b07e1..6e1e9881f0 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ClusterConfig.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ClusterConfig.java
@@ -59,6 +59,7 @@ public ClusterConfig(int pdCnt, int storeCnt, int serverCnt) {
for (int i = 0; i < serverCnt; i++) {
ServerConfig serverConfig = new ServerConfig();
+ serverConfig.setPDPeersList(pdGrpcList);
serverConfigs.add(serverConfig);
GraphConfig graphConfig = new GraphConfig();
graphConfig.setPDPeersList(pdGrpcList);
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/GremlinServerConfig.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/GremlinServerConfig.java
new file mode 100644
index 0000000000..64950f83e3
--- /dev/null
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/GremlinServerConfig.java
@@ -0,0 +1,69 @@
+/*
+ * 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.ct.config;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.regex.Pattern;
+
+public final class GremlinServerConfig {
+
+ private static final Pattern HOST_SETTING =
+ Pattern.compile("^#?host\\s*:.*$");
+ private static final Pattern PORT_SETTING =
+ Pattern.compile("^#?port\\s*:.*$");
+
+ private GremlinServerConfig() {
+ throw new IllegalStateException("Utility class");
+ }
+
+ public static void update(Path configPath, String host, int port) {
+ if (port <= 0 || port > 65535) {
+ throw new IllegalArgumentException("Invalid Gremlin port: " + port);
+ }
+
+ try {
+ List lines = Files.readAllLines(configPath,
+ StandardCharsets.UTF_8);
+ int hostSettings = 0;
+ int portSettings = 0;
+ for (int i = 0; i < lines.size(); i++) {
+ String line = lines.get(i);
+ if (HOST_SETTING.matcher(line).matches()) {
+ lines.set(i, "host: " + host);
+ hostSettings++;
+ } else if (PORT_SETTING.matcher(line).matches()) {
+ lines.set(i, "port: " + port);
+ portSettings++;
+ }
+ }
+
+ if (hostSettings != 1 || portSettings != 1) {
+ throw new IllegalStateException(
+ "Expected one top-level host and port in " + configPath);
+ }
+ Files.write(configPath, lines, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new IllegalStateException(
+ "Failed to update Gremlin server config " + configPath, e);
+ }
+ }
+}
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ServerConfig.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ServerConfig.java
index 569a11dddf..2840c946b9 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ServerConfig.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/config/ServerConfig.java
@@ -24,6 +24,7 @@
import static org.apache.hugegraph.ct.base.EnvUtil.getAvailablePort;
import java.nio.file.Paths;
+import java.util.List;
import lombok.Getter;
@@ -32,16 +33,24 @@ public class ServerConfig extends AbstractConfig {
private final int rpcPort;
private final int restPort;
+ private final int gremlinPort;
public ServerConfig() {
readTemplate(Paths.get(CONFIG_FILE_PATH + SERVER_TEMPLATE_FILE));
this.fileName = SERVER_PROPERTIES;
this.rpcPort = getAvailablePort();
this.restPort = getAvailablePort();
+ this.gremlinPort = getAvailablePort();
properties.put("REST_SERVER_ADDRESS", LOCALHOST + ":" + this.restPort);
+ properties.put("GREMLIN_SERVER_ADDRESS",
+ LOCALHOST + ":" + this.gremlinPort);
properties.put("RPC_PORT", String.valueOf(this.rpcPort));
}
+ public void setPDPeersList(List pdPeersList) {
+ setProperty("PD_PEERS_LIST", String.join(",", pdPeersList));
+ }
+
public void setServerID(String serverID) {
setProperty("SERVER_ID", serverID);
}
@@ -51,4 +60,3 @@ public void setRole(String role) {
}
}
-
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/AbstractEnv.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/AbstractEnv.java
index 0c24860929..ccf0582a00 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/AbstractEnv.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/AbstractEnv.java
@@ -21,6 +21,7 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.TimeUnit;
import org.apache.hugegraph.ct.base.HGTestLogger;
import org.apache.hugegraph.ct.config.ClusterConfig;
@@ -28,6 +29,7 @@
import org.apache.hugegraph.ct.config.PDConfig;
import org.apache.hugegraph.ct.config.ServerConfig;
import org.apache.hugegraph.ct.config.StoreConfig;
+import org.apache.hugegraph.ct.node.AbstractNodeWrapper;
import org.apache.hugegraph.ct.node.PDNodeWrapper;
import org.apache.hugegraph.ct.node.ServerNodeWrapper;
import org.apache.hugegraph.ct.node.StoreNodeWrapper;
@@ -40,6 +42,8 @@
public abstract class AbstractEnv implements BaseEnv {
private static final Logger LOG = HGTestLogger.ENV_LOG;
+ private static final int NODE_START_TIMEOUT_SECONDS = 120;
+ private static final int NODE_START_POLL_MILLIS = 1000;
protected ClusterConfig clusterConfig;
protected List pdNodeWrappers;
@@ -71,9 +75,12 @@ protected void init(int pdCnt, int storeCnt, int serverCnt) {
}
for (int i = 0; i < serverCnt; i++) {
- ServerNodeWrapper serverNodeWrapper = new ServerNodeWrapper(cluster_id, i);
- serverNodeWrappers.add(serverNodeWrapper);
ServerConfig serverConfig = clusterConfig.getServerConfig(i);
+ ServerNodeWrapper serverNodeWrapper =
+ new ServerNodeWrapper(cluster_id, i,
+ serverConfig.getRestPort(),
+ serverConfig.getGremlinPort());
+ serverNodeWrappers.add(serverNodeWrapper);
serverConfig.setServerID(serverNodeWrapper.getID());
GraphConfig graphConfig = clusterConfig.getGraphConfig(i);
if (i == 0) {
@@ -88,34 +95,54 @@ protected void init(int pdCnt, int storeCnt, int serverCnt) {
public void startCluster() {
for (PDNodeWrapper pdNodeWrapper : pdNodeWrappers) {
- pdNodeWrapper.start();
- while (!pdNodeWrapper.isStarted()) {
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- }
+ startNode(pdNodeWrapper);
}
for (StoreNodeWrapper storeNodeWrapper : storeNodeWrappers) {
- storeNodeWrapper.start();
- while (!storeNodeWrapper.isStarted()) {
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
- }
+ startNode(storeNodeWrapper);
}
for (ServerNodeWrapper serverNodeWrapper : serverNodeWrappers) {
- serverNodeWrapper.start();
- while (!serverNodeWrapper.isStarted()) {
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- throw new RuntimeException(e);
- }
+ startNode(serverNodeWrapper);
+ }
+ }
+
+ private void startNode(AbstractNodeWrapper nodeWrapper) {
+ System.out.printf("[cluster-test] starting %s in %s%n",
+ nodeWrapper.getID(), nodeWrapper.getNodePath());
+ nodeWrapper.start();
+ waitUntilStarted(nodeWrapper);
+ }
+
+ private static void waitUntilStarted(AbstractNodeWrapper nodeWrapper) {
+ long deadline = System.nanoTime() +
+ TimeUnit.SECONDS.toNanos(NODE_START_TIMEOUT_SECONDS);
+ while (System.nanoTime() < deadline) {
+ if (!nodeWrapper.isAlive()) {
+ nodeWrapper.dumpLog();
+ throw new AssertionError(String.format(
+ "%s failed to start, process status: %s",
+ nodeWrapper.getID(), nodeWrapper.processStatus()));
+ }
+ if (nodeWrapper.isStarted()) {
+ System.out.printf("[cluster-test] %s started%n",
+ nodeWrapper.getID());
+ return;
}
+ sleepBeforeRetry();
+ }
+
+ nodeWrapper.dumpLog();
+ throw new AssertionError(String.format(
+ "%s did not start within %s seconds, process status: %s",
+ nodeWrapper.getID(), NODE_START_TIMEOUT_SECONDS,
+ nodeWrapper.processStatus()));
+ }
+
+ private static void sleepBeforeRetry() {
+ try {
+ TimeUnit.MILLISECONDS.sleep(NODE_START_POLL_MILLIS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("Interrupted while waiting node start", e);
}
}
@@ -131,6 +158,22 @@ public void stopCluster() {
}
}
+ public void dumpClusterStatus() {
+ System.out.println("===== cluster node diagnostics =====");
+ dumpNodeStatus(pdNodeWrappers);
+ dumpNodeStatus(storeNodeWrappers);
+ dumpNodeStatus(serverNodeWrappers);
+ }
+
+ private static void dumpNodeStatus(
+ List extends AbstractNodeWrapper> nodeWrappers) {
+ for (AbstractNodeWrapper nodeWrapper : nodeWrappers) {
+ System.out.printf("[cluster-test] %s process status: %s%n",
+ nodeWrapper.getID(), nodeWrapper.processStatus());
+ nodeWrapper.dumpLog();
+ }
+ }
+
public ClusterConfig getConf() {
return this.clusterConfig;
}
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/BaseEnv.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/BaseEnv.java
index f6c4ba5fb6..422901959b 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/BaseEnv.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/env/BaseEnv.java
@@ -29,6 +29,8 @@ public interface BaseEnv {
/* clear the cluster env and all config*/
void stopCluster();
+ void dumpClusterStatus();
+
ClusterConfig getConf();
void init();
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/AbstractNodeWrapper.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/AbstractNodeWrapper.java
index 8236bb1392..15efdfc8b4 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/AbstractNodeWrapper.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/AbstractNodeWrapper.java
@@ -20,9 +20,8 @@
import static org.apache.hugegraph.ct.base.ClusterConstant.CT_PACKAGE_PATH;
import java.io.File;
-import java.io.FileNotFoundException;
-import java.io.FileReader;
import java.io.IOException;
+import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
@@ -147,20 +146,40 @@ public void updateConfigPath(String ConfigPath) {
@Override
public boolean isStarted() {
- try (Scanner sc = new Scanner(new FileReader(getLogPath()))) {
+ if (!isAlive()) {
+ return false;
+ }
+
+ try (Scanner sc = new Scanner(Paths.get(getLogPath()),
+ StandardCharsets.UTF_8.name())) {
while (sc.hasNextLine()) {
String line = sc.nextLine();
if (line.contains(startLine)) return true;
}
- } catch (FileNotFoundException ignored) {
+ } catch (IOException ignored) {
}
return false;
}
+ protected String moduleOptions(Path options) throws IOException {
+ if (!Files.isRegularFile(options) || !Files.isReadable(options)) {
+ throw new IOException("Missing or unreadable JVM module options: " + options);
+ }
+ return "@" + options.toAbsolutePath();
+ }
+
public void stop() {
if (this.instance == null) {
return;
}
+ if (!this.instance.isAlive()) {
+ System.out.printf("[cluster-test] %s stopped unexpectedly: %s%n",
+ getID(), processStatus());
+ if (dumpRuntimeLogs()) {
+ deleteDir();
+ }
+ return;
+ }
this.instance.destroy();
try {
if (!this.instance.waitFor(20, TimeUnit.SECONDS)) {
@@ -170,11 +189,70 @@ public void stop() {
Thread.currentThread().interrupt();
LOG.error("Waiting node to shutdown error.", e);
}
- deleteDir();
+ if (dumpRuntimeLogs()) {
+ deleteDir();
+ }
+ }
+
+ private boolean dumpRuntimeLogs() {
+ Path startupLog = Paths.get(getLogPath());
+ try {
+ if (this.instance.isAlive() || !Files.isReadable(startupLog)) {
+ throw new IOException("Node still running or startup log unavailable: " + getID());
+ }
+ // Stream complete text logs into the suite output before deletion.
+ // The suite's existing checker validates them without an archive.
+ try (Stream logs = Files.walk(startupLog.getParent())) {
+ for (Path log : (Iterable) logs.filter(Files::isRegularFile)::iterator) {
+ if (!log.getFileName().toString().matches(".*(\\.log(\\.[0-9]+)?|\\.out|\\.err|\\.txt)")) {
+ continue;
+ }
+ System.out.println("[cluster-test] runtime log: " + log);
+ try (Stream lines = Files.lines(log, StandardCharsets.UTF_8)) {
+ lines.forEach(System.out::println);
+ }
+ }
+ }
+ return true;
+ } catch (IOException | UncheckedIOException e) {
+ // Keep the original files and continue stopping the other nodes.
+ // This explicit marker makes the suite's log checker fail.
+ System.out.println("Unable to inspect cluster runtime logs: " + e.getMessage());
+ return false;
+ }
}
public boolean isAlive() {
- return this.instance.isAlive();
+ return this.instance != null && this.instance.isAlive();
+ }
+
+ public String processStatus() {
+ if (this.instance == null) {
+ return "not started";
+ }
+ if (this.instance.isAlive()) {
+ return "alive";
+ }
+ return "exited with code " + this.instance.exitValue();
+ }
+
+ public void dumpLog() {
+ Path logPath = Paths.get(getLogPath());
+ System.out.println("===== " + getID() + " log: " + logPath + " =====");
+ if (!Files.exists(logPath)) {
+ System.out.println("Log file does not exist");
+ return;
+ }
+
+ try {
+ List lines = Files.readAllLines(logPath, StandardCharsets.UTF_8);
+ int start = Math.max(0, lines.size() - 200);
+ for (int i = start; i < lines.size(); i++) {
+ System.out.println(lines.get(i));
+ }
+ } catch (IOException e) {
+ System.out.println("Failed to read log file: " + e.getMessage());
+ }
}
protected ProcessBuilder runCmd(List startCmd, File stdoutFile) throws IOException {
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/PDNodeWrapper.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/PDNodeWrapper.java
index a89c614c4c..43ecc2be6c 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/PDNodeWrapper.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/PDNodeWrapper.java
@@ -17,14 +17,16 @@
package org.apache.hugegraph.ct.node;
+import static org.apache.hugegraph.ct.base.ClusterConstant.BIN_DIR;
import static org.apache.hugegraph.ct.base.ClusterConstant.CONF_DIR;
+import static org.apache.hugegraph.ct.base.ClusterConstant.JVM_MODULE_OPTIONS_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.JAVA_CMD;
import static org.apache.hugegraph.ct.base.ClusterConstant.LOG4J_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.PD_JAR_PREFIX;
import static org.apache.hugegraph.ct.base.ClusterConstant.PD_LIB_PATH;
import static org.apache.hugegraph.ct.base.ClusterConstant.PD_TEMPLATE_PATH;
import static org.apache.hugegraph.ct.base.ClusterConstant.getFileInDir;
-import static org.apache.hugegraph.ct.base.ClusterConstant.isJava11OrHigher;
+import static org.apache.hugegraph.ct.base.ClusterConstant.isJava17OrHigher;
import java.io.File;
import java.io.IOException;
@@ -62,11 +64,14 @@ public void start() {
File stdoutFile = new File(getLogPath());
List startCmd = new ArrayList<>();
startCmd.add(JAVA_CMD);
- if (!isJava11OrHigher()) {
- LOG.error("Please make sure that the JDK is installed and the version >= 11");
+ if (!isJava17OrHigher()) {
+ LOG.error("Please make sure that the JDK is installed and the version >= 17");
return;
}
+ startCmd.add(moduleOptions(Paths.get(workPath).getParent()
+ .resolve(BIN_DIR).resolve(JVM_MODULE_OPTIONS_FILE)));
+
String pdNodeJarPath = getFileInDir(workPath, PD_JAR_PREFIX);
startCmd.addAll(Arrays.asList(
"-Dname=HugeGraphPD" + this.index,
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/ServerNodeWrapper.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/ServerNodeWrapper.java
index e16b96781e..99f31dbf4f 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/ServerNodeWrapper.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/ServerNodeWrapper.java
@@ -17,6 +17,7 @@
package org.apache.hugegraph.ct.node;
+import static org.apache.hugegraph.ct.base.ClusterConstant.BIN_DIR;
import static org.apache.hugegraph.ct.base.ClusterConstant.CONF_DIR;
import static org.apache.hugegraph.ct.base.ClusterConstant.EMPTY_SAMPLE_GROOVY_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.EXAMPLE_GROOVY_FILE;
@@ -24,15 +25,18 @@
import static org.apache.hugegraph.ct.base.ClusterConstant.GREMLIN_DRIVER_SETTING_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.GREMLIN_SERVER_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.JAVA_CMD;
+import static org.apache.hugegraph.ct.base.ClusterConstant.JVM_MODULE_OPTIONS_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.LIB_DIR;
import static org.apache.hugegraph.ct.base.ClusterConstant.LOG4J_FILE;
+import static org.apache.hugegraph.ct.base.ClusterConstant.LOCALHOST;
import static org.apache.hugegraph.ct.base.ClusterConstant.PLUGINS_DIR;
import static org.apache.hugegraph.ct.base.ClusterConstant.REMOTE_OBJECTS_SETTING_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.REMOTE_SETTING_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.SERVER_LIB_PATH;
import static org.apache.hugegraph.ct.base.ClusterConstant.SERVER_PACKAGE_PATH;
import static org.apache.hugegraph.ct.base.ClusterConstant.SERVER_TEMPLATE_PATH;
-import static org.apache.hugegraph.ct.base.ClusterConstant.isJava11OrHigher;
+import static org.apache.hugegraph.ct.base.ClusterConstant.isJava17OrHigher;
+import static org.apache.hugegraph.ct.base.EnvUtil.isPortOpen;
import java.io.BufferedReader;
import java.io.File;
@@ -46,18 +50,39 @@
import java.util.Collections;
import java.util.List;
+import org.apache.hugegraph.ct.config.GremlinServerConfig;
+
public class ServerNodeWrapper extends AbstractNodeWrapper {
private static List hgJars = loadHgJarsOnce();
+ private final int restPort;
+ private final int gremlinPort;
+
public ServerNodeWrapper(int clusterIndex, int index) {
+ this(clusterIndex, index, -1, -1);
+ }
+
+ public ServerNodeWrapper(int clusterIndex, int index, int restPort) {
+ this(clusterIndex, index, restPort, -1);
+ }
+
+ public ServerNodeWrapper(int clusterIndex, int index, int restPort,
+ int gremlinPort) {
super(clusterIndex, index);
+ this.restPort = restPort;
+ this.gremlinPort = gremlinPort;
this.fileNames = new ArrayList<>(
List.of(LOG4J_FILE, GREMLIN_SERVER_FILE, GREMLIN_DRIVER_SETTING_FILE,
REMOTE_SETTING_FILE, REMOTE_OBJECTS_SETTING_FILE));
this.workPath = SERVER_LIB_PATH;
createNodeDir(Paths.get(SERVER_TEMPLATE_PATH), getNodePath() + CONF_DIR + File.separator);
+ if (this.gremlinPort >= 0) {
+ GremlinServerConfig.update(
+ Paths.get(getNodePath(), CONF_DIR, GREMLIN_SERVER_FILE),
+ LOCALHOST, this.gremlinPort);
+ }
this.fileNames = new ArrayList<>(List.of(EMPTY_SAMPLE_GROOVY_FILE, EXAMPLE_GROOVY_FILE));
- this.startLine = "INFO: [HttpServer] Started.";
+ this.startLine = "Channel started at port ";
createNodeDir(Paths.get(SERVER_PACKAGE_PATH), getNodePath());
createLogDir();
}
@@ -111,8 +136,8 @@ public void start() {
File stdoutFile = new File(getLogPath());
List startCmd = new ArrayList<>();
startCmd.add(JAVA_CMD);
- if (!isJava11OrHigher()) {
- LOG.error("Please make sure that the JDK is installed and the version >= 11");
+ if (!isJava17OrHigher()) {
+ LOG.error("Please make sure that the JDK is installed and the version >= 17");
return;
}
@@ -124,9 +149,8 @@ public void start() {
startCmd.addAll(Arrays.asList(
"-Dname=HugeGraphServer" + this.index,
- "--add-exports=java.base/jdk.internal.reflect=ALL-UNNAMED",
- "--add-modules=jdk.unsupported",
- "--add-exports=java.base/sun.nio.ch=ALL-UNNAMED",
+ moduleOptions(Paths.get(SERVER_PACKAGE_PATH, BIN_DIR,
+ JVM_MODULE_OPTIONS_FILE)),
"-cp", storeClassPath,
"org.apache.hugegraph.dist.HugeGraphServer",
"./conf/gremlin-server.yaml",
@@ -138,6 +162,14 @@ public void start() {
}
}
+ @Override
+ public boolean isStarted() {
+ return super.isStarted() &&
+ (this.restPort < 0 || isPortOpen(LOCALHOST, this.restPort)) &&
+ (this.gremlinPort < 0 ||
+ isPortOpen(LOCALHOST, this.gremlinPort));
+ }
+
@Override
public String getID() {
return "Server" + this.index;
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/StoreNodeWrapper.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/StoreNodeWrapper.java
index 1cb0f67eae..ed7225f5a6 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/StoreNodeWrapper.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/main/java/org/apache/hugegraph/ct/node/StoreNodeWrapper.java
@@ -17,14 +17,16 @@
package org.apache.hugegraph.ct.node;
+import static org.apache.hugegraph.ct.base.ClusterConstant.BIN_DIR;
import static org.apache.hugegraph.ct.base.ClusterConstant.CONF_DIR;
+import static org.apache.hugegraph.ct.base.ClusterConstant.JVM_MODULE_OPTIONS_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.JAVA_CMD;
import static org.apache.hugegraph.ct.base.ClusterConstant.LOG4J_FILE;
import static org.apache.hugegraph.ct.base.ClusterConstant.STORE_JAR_PREFIX;
import static org.apache.hugegraph.ct.base.ClusterConstant.STORE_LIB_PATH;
import static org.apache.hugegraph.ct.base.ClusterConstant.STORE_TEMPLATE_PATH;
import static org.apache.hugegraph.ct.base.ClusterConstant.getFileInDir;
-import static org.apache.hugegraph.ct.base.ClusterConstant.isJava11OrHigher;
+import static org.apache.hugegraph.ct.base.ClusterConstant.isJava17OrHigher;
import java.io.File;
import java.io.IOException;
@@ -59,11 +61,14 @@ public void start() {
File stdoutFile = new File(getLogPath());
List startCmd = new ArrayList<>();
startCmd.add(JAVA_CMD);
- if (!isJava11OrHigher()) {
- LOG.error("Please make sure that the JDK is installed and the version >= 11");
+ if (!isJava17OrHigher()) {
+ LOG.error("Please make sure that the JDK is installed and the version >= 17");
return;
}
+ startCmd.add(moduleOptions(Paths.get(workPath).getParent()
+ .resolve(BIN_DIR).resolve(JVM_MODULE_OPTIONS_FILE)));
+
String storeNodeJarPath = getFileInDir(workPath, STORE_JAR_PREFIX);
startCmd.addAll(Arrays.asList(
"-Dname=HugeGraphStore" + this.index,
diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/exception/LimitExceedException.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/base/EnvUtilTest.java
similarity index 63%
rename from hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/exception/LimitExceedException.java
rename to hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/base/EnvUtilTest.java
index 1c2950f11c..00fc93bf8d 100644
--- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/exception/LimitExceedException.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/base/EnvUtilTest.java
@@ -15,19 +15,23 @@
* limitations under the License.
*/
-package org.apache.hugegraph.exception;
+package org.apache.hugegraph.ct.base;
-import org.apache.hugegraph.HugeException;
+import java.net.ServerSocket;
-public class LimitExceedException extends HugeException {
+import org.junit.Assert;
+import org.junit.Test;
- private static final long serialVersionUID = 7384276720045597709L;
+public class EnvUtilTest {
- public LimitExceedException(String message) {
- super(message);
- }
+ @Test
+ public void testPortOpen() throws Exception {
+ int port;
+ try (ServerSocket socket = new ServerSocket(0)) {
+ port = socket.getLocalPort();
+ Assert.assertTrue(EnvUtil.isPortOpen("127.0.0.1", port));
+ }
- public LimitExceedException(String message, Object... args) {
- super(message, args);
+ Assert.assertFalse(EnvUtil.isPortOpen("127.0.0.1", port));
}
}
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/config/GremlinServerConfigTest.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/config/GremlinServerConfigTest.java
new file mode 100644
index 0000000000..ef3ffd6538
--- /dev/null
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/config/GremlinServerConfigTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.ct.config;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.List;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class GremlinServerConfigTest {
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testUpdateCommentedHostAndPort() throws Exception {
+ File config = temporaryFolder.newFile("gremlin-server.yaml");
+ String content = "#host: 127.0.0.1\n" +
+ "#port: 8182\n" +
+ "evaluationTimeout: 30000\n";
+ Files.write(config.toPath(), content.getBytes(StandardCharsets.UTF_8));
+
+ GremlinServerConfig.update(config.toPath(), "127.0.0.1", 12345);
+
+ List updated = Files.readAllLines(config.toPath(),
+ StandardCharsets.UTF_8);
+ Assert.assertTrue(updated.toString(),
+ updated.contains("host: 127.0.0.1"));
+ Assert.assertTrue(updated.toString(), updated.contains("port: 12345"));
+ Assert.assertFalse(updated.toString(), updated.contains("port: 8182"));
+ }
+
+ @Test
+ public void testRejectInvalidPortsWithoutChangingConfig() throws Exception {
+ File config = temporaryFolder.newFile("invalid-port.yaml");
+ String content = "host: 127.0.0.1\nport: 8182\n";
+ Files.writeString(config.toPath(), content, StandardCharsets.UTF_8);
+
+ for (int port : new int[]{-1, 0, 65536}) {
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ GremlinServerConfig.update(config.toPath(), "127.0.0.1", port);
+ });
+ Assert.assertEquals(content, Files.readString(config.toPath(),
+ StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ public void testRequireOneTopLevelHostAndPort() throws Exception {
+ File config = temporaryFolder.newFile("ambiguous-endpoint.yaml");
+ for (String content : new String[]{
+ "host: 127.0.0.1\n",
+ "port: 8182\n",
+ "host: 127.0.0.1\n#host: 0.0.0.0\nport: 8182\n",
+ "host: 127.0.0.1\nport: 8182\n#port: 8183\n"}) {
+ Files.writeString(config.toPath(), content, StandardCharsets.UTF_8);
+ Assert.assertThrows(IllegalStateException.class, () -> {
+ GremlinServerConfig.update(config.toPath(), "127.0.0.1", 12345);
+ });
+ Assert.assertEquals(content, Files.readString(config.toPath(),
+ StandardCharsets.UTF_8));
+ }
+ }
+
+}
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/node/AbstractNodeWrapperTest.java b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/node/AbstractNodeWrapperTest.java
new file mode 100644
index 0000000000..485810112b
--- /dev/null
+++ b/hugegraph-cluster-test/hugegraph-clustertest-minicluster/src/test/java/org/apache/hugegraph/ct/node/AbstractNodeWrapperTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.ct.node;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class AbstractNodeWrapperTest {
+
+ private static final String START_LINE = "node is ready";
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testStartedRequiresLiveProcess() throws Exception {
+ TestNodeWrapper wrapper = new TestNodeWrapper(newNodeDirectory());
+ Files.write(Paths.get(wrapper.getLogPath()),
+ START_LINE.getBytes(StandardCharsets.UTF_8));
+
+ Assert.assertFalse(wrapper.isStarted());
+
+ Process process = startSleeperProcess();
+ wrapper.attach(process);
+ try {
+ Assert.assertTrue(process.isAlive());
+ Assert.assertTrue(wrapper.isStarted());
+ } finally {
+ process.destroyForcibly();
+ process.waitFor(10, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ public void testStopDumpsLogOfUnexpectedlyExitedProcess() throws Exception {
+ TestNodeWrapper wrapper = new TestNodeWrapper(newNodeDirectory());
+ String failure = "fatal startup failure";
+ Files.write(Paths.get(wrapper.getLogPath()),
+ failure.getBytes(StandardCharsets.UTF_8));
+ Process process = startExitedProcess();
+ wrapper.attach(process);
+
+ PrintStream originalOut = System.out;
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try {
+ System.setOut(new PrintStream(output, true,
+ StandardCharsets.UTF_8.name()));
+ wrapper.stop();
+ } finally {
+ System.setOut(originalOut);
+ }
+
+ String diagnostics = output.toString(StandardCharsets.UTF_8.name());
+ Assert.assertTrue(diagnostics, diagnostics.contains(failure));
+ }
+
+ @Test
+ public void testModuleOptionsRejectMissingFile() throws Exception {
+ TestNodeWrapper wrapper = new TestNodeWrapper(newNodeDirectory());
+ Path options = temporaryFolder.newFile("jvm-module.options").toPath();
+ Assert.assertEquals("@" + options.toAbsolutePath(), wrapper.moduleOptions(options));
+ Files.delete(options);
+ Assert.assertThrows(IOException.class, () -> wrapper.moduleOptions(options));
+ }
+
+ @Test
+ public void testStopForwardsStartupLogBeforeCleanup() throws Exception {
+ File nodeDirectory = newNodeDirectory();
+ TestNodeWrapper wrapper = new TestNodeWrapper(nodeDirectory);
+ Files.writeString(Paths.get(wrapper.getLogPath()),
+ "startup diagnostic\n" + "operation succeeded\n".repeat(250));
+ wrapper.attach(startSleeperProcess());
+ PrintStream originalOut = System.out;
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try {
+ System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8.name()));
+ wrapper.stop();
+ } finally {
+ System.setOut(originalOut);
+ }
+ Assert.assertTrue(output.toString(StandardCharsets.UTF_8.name()).contains("startup diagnostic"));
+ Assert.assertFalse(nodeDirectory.exists());
+ }
+
+ @Test
+ public void testStopRetainsDirectoryWhenLogIsUnavailable() throws Exception {
+ for (boolean malformed : new boolean[]{false, true}) {
+ File nodeDirectory = newNodeDirectory();
+ TestNodeWrapper wrapper = new TestNodeWrapper(nodeDirectory);
+ if (malformed) {
+ Files.write(Paths.get(wrapper.getLogPath()), new byte[]{(byte) 0xff});
+ }
+ wrapper.attach(startSleeperProcess());
+ PrintStream originalOut = System.out;
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try {
+ System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8.name()));
+ wrapper.stop();
+ } finally {
+ System.setOut(originalOut);
+ }
+ Assert.assertFalse(wrapper.isAlive());
+ Assert.assertTrue(nodeDirectory.exists());
+ Assert.assertTrue(output.toString(StandardCharsets.UTF_8.name())
+ .contains("Unable to inspect cluster runtime logs:"));
+ }
+ }
+
+ private File newNodeDirectory() throws Exception {
+ File nodeDirectory = temporaryFolder.newFolder();
+ File logDirectory = new File(nodeDirectory, "logs");
+ Assert.assertTrue(logDirectory.mkdir());
+ return nodeDirectory;
+ }
+
+ private static Process startSleeperProcess() throws Exception {
+ String java = javaExecutable();
+ String classpath = testClassesPath();
+ return new ProcessBuilder(java, "-cp", classpath,
+ Sleeper.class.getName()).start();
+ }
+
+ private static Process startExitedProcess() throws Exception {
+ Process process = new ProcessBuilder(javaExecutable(), "-version").start();
+ Assert.assertTrue(process.waitFor(10, TimeUnit.SECONDS));
+ return process;
+ }
+
+ private static String javaExecutable() {
+ String executable = System.getProperty("os.name").startsWith("Windows") ?
+ "java.exe" : "java";
+ return Paths.get(System.getProperty("java.home"), "bin", executable)
+ .toString();
+ }
+
+ private static String testClassesPath() throws URISyntaxException {
+ return Paths.get(AbstractNodeWrapperTest.class.getProtectionDomain()
+ .getCodeSource()
+ .getLocation()
+ .toURI())
+ .toString();
+ }
+
+ public static class Sleeper {
+
+ public static void main(String[] args) throws Exception {
+ TimeUnit.SECONDS.sleep(30);
+ }
+ }
+
+ private static class TestNodeWrapper extends AbstractNodeWrapper {
+
+ private File nodeDirectory;
+
+ private TestNodeWrapper(File nodeDirectory) {
+ this.nodeDirectory = nodeDirectory;
+ this.startLine = START_LINE;
+ }
+
+ private void attach(Process process) {
+ this.instance = process;
+ }
+
+ @Override
+ public void start() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String getID() {
+ return "TestNode";
+ }
+
+ @Override
+ public String getNodePath() {
+ if (this.nodeDirectory == null) {
+ return System.getProperty("java.io.tmpdir") + File.separator;
+ }
+ return this.nodeDirectory.getAbsolutePath() + File.separator;
+ }
+ }
+}
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-test/pom.xml b/hugegraph-cluster-test/hugegraph-clustertest-test/pom.xml
index 735ea66b43..1ab0fd3b7f 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-test/pom.xml
+++ b/hugegraph-cluster-test/hugegraph-clustertest-test/pom.xml
@@ -29,8 +29,6 @@
hugegraph-clustertest-test
- 11
- 11UTF-8
@@ -40,6 +38,11 @@
${revision}compile
+
+
+ org.apache.hugegraph
+ hugegraph-common
+ org.apache.hugegraph
@@ -81,7 +84,6 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20simple-cluster-test
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/MultiClusterTest/BaseMultiClusterTest.java b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/MultiClusterTest/BaseMultiClusterTest.java
index 9e90933026..0f2b0d492a 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/MultiClusterTest/BaseMultiClusterTest.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/MultiClusterTest/BaseMultiClusterTest.java
@@ -38,7 +38,7 @@
* MultiNode Test generate the cluster env with 3 pd node + 3 store node + 3 server node.
* Or you can set different num of nodes by using env = new MultiNodeEnv(pdNum, storeNum, serverNum)
* All nodes are deployed in ports generated randomly, the application of nodes are stored
- * in /apache-hugegraph-ct-1.7.0, you can visit each node with rest api.
+ * in /apache-hugegraph-ct-1.8.0, you can visit each node with rest api.
*/
public class BaseMultiClusterTest {
@@ -115,8 +115,21 @@ protected static String assertResponseStatus(int status,
public static Response createAndAssert(RestClient client, String path,
String body,
int status) {
- Response r = client.post(path, body);
- assertResponseStatus(status, r);
- return r;
+ try {
+ Response r = client.post(path, body);
+ assertResponseStatus(status, r);
+ return r;
+ } catch (RuntimeException | AssertionError e) {
+ System.out.printf("[cluster-test] POST %s%s failed: %s%n",
+ client.target().getUri(), path, e.getMessage());
+ try {
+ env.dumpClusterStatus();
+ } catch (Throwable diagnosticError) {
+ if (diagnosticError != e) {
+ e.addSuppressed(diagnosticError);
+ }
+ }
+ throw e;
+ }
}
}
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/BaseSimpleTest.java b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/BaseSimpleTest.java
index f0f0c33461..996856f86c 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/BaseSimpleTest.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/BaseSimpleTest.java
@@ -45,7 +45,7 @@
/**
* Simple Test generate the cluster env with 1 pd node + 1 store node + 1 server node.
* All nodes are deployed in ports generated randomly; The application of nodes is stored
- * in /apache-hugegraph-ct-1.7.0, you can visit each node with rest api.
+ * in /apache-hugegraph-ct-1.8.0, you can visit each node with rest api.
*/
public class BaseSimpleTest {
@@ -74,7 +74,10 @@ public static void initEnv() {
public static void clearEnv() throws InterruptedException {
env.stopCluster();
Thread.sleep(2000);
- client.close();
+ if (client != null) {
+ client.close();
+ client = null;
+ }
}
protected String execCmd(String[] cmds) throws IOException {
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterGraphTest.java b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterGraphTest.java
new file mode 100644
index 0000000000..97ac4ed4da
--- /dev/null
+++ b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterGraphTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.SimpleClusterTest;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableMap;
+
+import jakarta.ws.rs.core.Response;
+
+/**
+ * Writes graph data through the server and reads it back, so the request path
+ * crosses Server, the HStore backend and the Store node of the simple cluster.
+ */
+public class SimpleClusterGraphTest extends BaseSimpleTest {
+
+ private static final String SCHEMA = URL_PREFIX + "/schema";
+ private static final String VERTICES = URL_PREFIX + "/graph/vertices";
+ private static final String EDGES = URL_PREFIX + "/graph/edges";
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @BeforeClass
+ public static void initSchema() {
+ createPropertyKey("ct_name", "TEXT");
+ createPropertyKey("ct_age", "INT");
+ createPropertyKey("ct_weight", "DOUBLE");
+ createAndAssert(SCHEMA + "/vertexlabels", "{"
+ + "\"name\": \"ct_person\","
+ + "\"id_strategy\": \"PRIMARY_KEY\","
+ + "\"properties\": [\"ct_name\", \"ct_age\"],"
+ + "\"primary_keys\": [\"ct_name\"],"
+ + "\"check_exist\": false"
+ + "}", 201);
+ createAndAssert(SCHEMA + "/edgelabels", "{"
+ + "\"name\": \"ct_knows\","
+ + "\"source_label\": \"ct_person\","
+ + "\"target_label\": \"ct_person\","
+ + "\"properties\": [\"ct_weight\"],"
+ + "\"check_exist\": false"
+ + "}", 201);
+ }
+
+ @Test
+ public void testVertexAndEdgeRoundTripThroughStore() throws IOException {
+ String alice = createPerson("ct-alice", 30);
+ String bob = createPerson("ct-bob", 31);
+
+ Map, ?> vertex = read(assertResponseStatus(200, client.get(VERTICES, json(alice))));
+ Assert.assertEquals(alice, vertex.get("id"));
+ Assert.assertEquals("ct_person", vertex.get("label"));
+ Map, ?> props = (Map, ?>) vertex.get("properties");
+ Assert.assertEquals("ct-alice", props.get("ct_name"));
+ Assert.assertEquals(30, props.get("ct_age"));
+
+ Response r = client.post(EDGES, "{"
+ + "\"label\": \"ct_knows\","
+ + "\"outV\": " + json(alice) + ","
+ + "\"inV\": " + json(bob) + ","
+ + "\"outVLabel\": \"ct_person\","
+ + "\"inVLabel\": \"ct_person\","
+ + "\"properties\": {\"ct_weight\": 0.5}"
+ + "}");
+ String edgeId = (String) read(assertResponseStatus(201, r)).get("id");
+
+ Map, ?> edge = read(assertResponseStatus(200, client.get(EDGES, edgeId)));
+ Assert.assertEquals(alice, edge.get("outV"));
+ Assert.assertEquals(bob, edge.get("inV"));
+ Assert.assertEquals(0.5, ((Map, ?>) edge.get("properties")).get("ct_weight"));
+
+ // Adjacency is served from the Store side, so query it by direction
+ List> out = edges(alice, "OUT");
+ Assert.assertEquals(1, out.size());
+ Assert.assertEquals(edgeId, ((Map, ?>) out.get(0)).get("id"));
+ Assert.assertEquals(1, edges(bob, "IN").size());
+ Assert.assertEquals(0, edges(bob, "OUT").size());
+ }
+
+ @Test
+ public void testInvalidGraphRequestsAreRejected() {
+ Response r = client.post(VERTICES, "{"
+ + "\"label\": \"ct_undefined_label\","
+ + "\"properties\": {\"ct_name\": \"x\"}"
+ + "}");
+ assertResponseStatus(400, r);
+
+ r = client.post(VERTICES, "{"
+ + "\"label\": \"ct_person\","
+ + "\"properties\": {\"ct_name\": \"x\", \"ct_age\": \"old\"}"
+ + "}");
+ assertResponseStatus(400, r);
+
+ // A primary key vertex can't be created without its primary key value
+ r = client.post(VERTICES, "{"
+ + "\"label\": \"ct_person\","
+ + "\"properties\": {\"ct_age\": 1}"
+ + "}");
+ assertResponseStatus(400, r);
+
+ r = client.get(VERTICES, json("ct-person-never-written"));
+ assertResponseStatus(404, r);
+ }
+
+ private static void createPropertyKey(String name, String dataType) {
+ createAndAssert(SCHEMA + "/propertykeys", "{"
+ + "\"name\": \"" + name + "\","
+ + "\"data_type\": \"" + dataType + "\","
+ + "\"cardinality\": \"SINGLE\","
+ + "\"check_exist\": false,"
+ + "\"properties\": []"
+ + "}", 202);
+ }
+
+ private static String createPerson(String name, int age) throws IOException {
+ Response r = client.post(VERTICES, "{"
+ + "\"label\": \"ct_person\","
+ + "\"properties\": {"
+ + "\"ct_name\": \"" + name + "\","
+ + "\"ct_age\": " + age
+ + "}}");
+ return (String) read(assertResponseStatus(201, r)).get("id");
+ }
+
+ private static List> edges(String vertexId, String direction) {
+ Response r = client.get(EDGES, ImmutableMap.of("vertex_id", json(vertexId),
+ "direction", direction));
+ try {
+ return (List>) read(assertResponseStatus(200, r)).get("edges");
+ } catch (IOException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static Map, ?> read(String content) throws IOException {
+ return MAPPER.readValue(content, Map.class);
+ }
+
+ private static String json(String id) {
+ return "\"" + id + "\"";
+ }
+}
diff --git a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java
index 7f24d8b46c..cc007a0802 100644
--- a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java
+++ b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java
@@ -26,6 +26,7 @@
@Suite.SuiteClasses({
SimpleClusterDeployTest.class,
SimpleClusterFileTest.class,
+ SimpleClusterGraphTest.class,
})
@Slf4j
public class SimpleClusterSuiteTest {
diff --git a/hugegraph-cluster-test/pom.xml b/hugegraph-cluster-test/pom.xml
index cd54ac0ffe..cf51dfd1f3 100644
--- a/hugegraph-cluster-test/pom.xml
+++ b/hugegraph-cluster-test/pom.xml
@@ -39,12 +39,29 @@
- 11
- 11UTF-8apache-${release.name}-ct-${project.version}
+
+
+
+
+ org.apache.hugegraph
+ hugegraph-common
+ ${revision}
+
+
+
+ org.apache.commons
+ commons-text
+ 1.10.0
+
+
+
+
com.google.code.findbugs
@@ -96,7 +113,6 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20simple-cluster-test
@@ -120,7 +136,6 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20multi-cluster-test
diff --git a/hugegraph-commons/AGENTS.md b/hugegraph-commons/AGENTS.md
index 179e01300c..58f4512c35 100644
--- a/hugegraph-commons/AGENTS.md
+++ b/hugegraph-commons/AGENTS.md
@@ -6,8 +6,8 @@ Shared rules: [root AGENTS.md](../AGENTS.md). Paths here are relative to this mo
- Config, locks, events, iterators and REST: `hugegraph-common/src/main/java/org/apache/hugegraph/`.
- RPC framework: `hugegraph-rpc/`, which depends on `hugegraph-common`.
-- Commons targets Java 8 bytecode in [pom.xml](pom.xml), although CI uses JDK 11.
- Do not infer its language/API baseline from Server's JDK requirement.
+- Commons and RPC use Java 11 bytecode while building and testing on JDK 17;
+ see [pom.xml](pom.xml) and the [root POM](../pom.xml).
## Tests
diff --git a/hugegraph-commons/README.md b/hugegraph-commons/README.md
index d8cbcbc24a..71f6dea421 100644
--- a/hugegraph-commons/README.md
+++ b/hugegraph-commons/README.md
@@ -21,6 +21,10 @@ numeric or collection util classes to simplify the development of HugeGraph and
- Util: performance analyzer, version checker, numeric and Collection utils, log and exception utils, etc.
- Rpc: rpc component for inner module communication, currently it's based on [Sofa-RPC](https://github.com/sofastack/sofa-rpc)
+The REST client serializes object bodies as JSON for `application/json`, including media types
+with parameters such as `application/json; charset=UTF-16`. Request bodies use the declared
+charset, or UTF-8 when none is specified; gzip preserves these encoded bytes.
+
You could use import the dependencies in `maven` like this:
```xml
diff --git a/hugegraph-commons/hugegraph-common/README.md b/hugegraph-commons/hugegraph-common/README.md
index bfeef03cd2..df04380dba 100644
--- a/hugegraph-commons/hugegraph-common/README.md
+++ b/hugegraph-commons/hugegraph-common/README.md
@@ -5,11 +5,26 @@
[](https://codecov.io/gh/hugegraph/hugegraph-common)
[](https://mvnrepository.com/artifact/org.apache.hugegraph/hugegraph-common)
-hugegraph-common is a common module for [HugeGraph](https://github.com/hugegraph/hugegraph) and its peripheral components.
+hugegraph-common is a common module for [HugeGraph](https://github.com/apache/hugegraph) and its peripheral components.
hugegraph-common encapsulates locks, configurations, events, iterators, rest and some
numeric or collection util classes to simplify the development of HugeGraph and
its components.
+## REST request encoding
+
+The shared REST client encodes request bodies using the charset declared in
+`Content-Type`, or UTF-8 when no charset is declared. JSON and text requests do
+not depend on the JVM's default charset. Gzip compresses those same encoded bytes.
+
+## HTTPS certificate verification
+
+A custom trust store configures which certificate authorities or certificates the
+REST client trusts. It does not disable server identity verification: the
+certificate must also contain a Subject Alternative Name matching the requested
+DNS name or IP address. Certificates with a different identity or no matching
+Subject Alternative Name are rejected, including certificates explicitly trusted
+by the configured store. This uses OkHttp's default hostname verifier.
+
## Components
- Lock: atomic lock, key lock, lock group and lock manager
diff --git a/hugegraph-commons/hugegraph-common/build.sh b/hugegraph-commons/hugegraph-common/build.sh
index b2cb6211b9..2e4b0bbc20 100644
--- a/hugegraph-commons/hugegraph-common/build.sh
+++ b/hugegraph-commons/hugegraph-common/build.sh
@@ -16,8 +16,5 @@
# limitations under the License.
#
-export MAVEN_HOME=/home/scmtools/buildkit/maven/apache-maven-3.3.9/
-export JAVA_HOME=/home/scmtools/buildkit/java/jdk1.8.0_25/
-export PATH=$JAVA_HOME/bin:$MAVEN_HOME/bin:$PATH
-
-mvn clean test -Dtest=UnitTestSuite
+mvn clean test -Dtest=UnitTestSuite \
+ -Dsurefire.failIfNoSpecifiedTests=true -DskipCommonsTests=false
diff --git a/hugegraph-commons/hugegraph-common/pom.xml b/hugegraph-commons/hugegraph-common/pom.xml
index 14f7cc217c..25c10e139a 100644
--- a/hugegraph-commons/hugegraph-common/pom.xml
+++ b/hugegraph-commons/hugegraph-common/pom.xml
@@ -39,6 +39,7 @@
4.10.0
+ 0.11.5
@@ -72,6 +73,28 @@
${log4j2.version}
+
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+ true
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+ true
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+ true
+
+
org.glassfish
@@ -164,22 +187,18 @@
com.fasterxml.jackson.corejackson-annotations
- ${jackson.version}com.fasterxml.jackson.corejackson-core
- ${jackson.version}com.fasterxml.jackson.corejackson-databind
- ${jackson.version}com.fasterxml.jackson.modulejackson-module-jaxb-annotations
- ${jackson.version}jakarta.xml.bind-api
@@ -190,12 +209,10 @@
com.fasterxml.jackson.jaxrsjackson-jaxrs-base
- ${jackson.version}com.fasterxml.jackson.jaxrsjackson-jaxrs-json-provider
- ${jackson.version}
@@ -257,7 +274,6 @@
org.jacocojacoco-maven-plugin
- 0.8.2pre-unit-test
diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/auth/AuthConstant.java b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/auth/AuthConstant.java
similarity index 88%
rename from hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/auth/AuthConstant.java
rename to hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/auth/AuthConstant.java
index 3c7c539363..089fb8aa59 100644
--- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/auth/AuthConstant.java
+++ b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/auth/AuthConstant.java
@@ -24,4 +24,6 @@ public interface AuthConstant {
*/
String TOKEN_USER_NAME = "user_name";
String TOKEN_USER_ID = "user_id";
+ // Retained for callers using the former struct constants; not emitted by login.
+ String TOKEN_USER_PASSWORD = "user_password";
}
diff --git a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/auth/TokenGenerator.java b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/auth/TokenGenerator.java
similarity index 64%
rename from hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/auth/TokenGenerator.java
rename to hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/auth/TokenGenerator.java
index 7c75c095b9..83fecb47e7 100644
--- a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/auth/TokenGenerator.java
+++ b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/auth/TokenGenerator.java
@@ -23,27 +23,16 @@
import javax.crypto.SecretKey;
-import org.apache.hugegraph.config.AuthOptions;
-import org.apache.hugegraph.config.HugeConfig;
-
import io.jsonwebtoken.Claims;
-import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
-import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
-import jakarta.ws.rs.NotAuthorizedException;
public class TokenGenerator {
private final SecretKey key;
- public TokenGenerator(HugeConfig config) {
- String secretKey = config.get(AuthOptions.AUTH_TOKEN_SECRET);
- this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
- }
-
public TokenGenerator(String secretKey) {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
@@ -56,17 +45,15 @@ public String create(Map payload, long expire) {
.compact();
}
+ /**
+ * Verify a signed token. Callers translate JJWT failures into their own
+ * authentication or transport errors.
+ */
public Claims verify(String token) {
- try {
- Jws claimsJws = Jwts.parserBuilder()
- .setSigningKey(key)
- .build()
- .parseClaimsJws(token);
- return claimsJws.getBody();
- } catch (ExpiredJwtException e) {
- throw new NotAuthorizedException("The token is expired", e);
- } catch (JwtException e) {
- throw new NotAuthorizedException("Invalid token", e);
- }
+ Jws claimsJws = Jwts.parserBuilder()
+ .setSigningKey(this.key)
+ .build()
+ .parseClaimsJws(token);
+ return claimsJws.getBody();
}
}
diff --git a/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/license/MachineInfo.java b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/license/MachineInfo.java
index cd106921ea..1b2f4a3271 100755
--- a/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/license/MachineInfo.java
+++ b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/license/MachineInfo.java
@@ -57,20 +57,19 @@ public List getMacAddress() {
if (this.macAddressList != null) {
return this.macAddressList;
}
- this.macAddressList = new ArrayList<>();
+ List list = new ArrayList<>();
List inetAddresses = this.getLocalAllInetAddress();
if (inetAddresses != null && !inetAddresses.isEmpty()) {
// Get the Mac address of all network interfaces
- List list = new ArrayList<>();
Set uniqueValues = new HashSet<>();
for (InetAddress inetAddress : inetAddresses) {
- String macByInetAddress = this.getMacByInetAddress(inetAddress);
- if (uniqueValues.add(macByInetAddress)) {
+ String macByInetAddress = this.getMacByInetAddress(inetAddress, false);
+ if (macByInetAddress != null && uniqueValues.add(macByInetAddress)) {
list.add(macByInetAddress);
}
}
- this.macAddressList = list;
}
+ this.macAddressList = list;
return this.macAddressList;
}
@@ -99,6 +98,10 @@ public List getLocalAllInetAddress() {
}
public String getMacByInetAddress(InetAddress inetAddr) {
+ return this.getMacByInetAddress(inetAddr, true);
+ }
+
+ private String getMacByInetAddress(InetAddress inetAddr, boolean required) {
byte[] mac;
try {
mac = NetworkInterface.getByInetAddress(inetAddr)
@@ -108,6 +111,12 @@ public String getMacByInetAddress(InetAddress inetAddr) {
"Failed to get mac address for inet address '%s'",
inetAddr));
}
+ if (mac == null) {
+ if (required) {
+ throw new IllegalStateException("Network interface has no hardware address");
+ }
+ return null;
+ }
StringBuilder sb = new StringBuilder();
for (int i = 0; i < mac.length; i++) {
diff --git a/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rest/AbstractRestClient.java b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rest/AbstractRestClient.java
index c13a686b71..a38dcb69a6 100644
--- a/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rest/AbstractRestClient.java
+++ b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rest/AbstractRestClient.java
@@ -20,7 +20,6 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
-import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
@@ -31,7 +30,6 @@
import java.util.concurrent.TimeUnit;
import javax.net.ssl.HostnameVerifier;
-import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocketFactory;
@@ -125,9 +123,10 @@ public AbstractRestClient(String url, RestClientConfig config) {
}
private static RequestBody buildRequestBody(Object body, RestHeaders headers) {
- String contentType = parseContentType(headers);
+ MediaType contentType = MediaType.parse(parseContentType(headers));
String bodyContent;
- if (RestHeaders.APPLICATION_JSON.equals(contentType)) {
+ if (contentType != null && "application".equals(contentType.type()) &&
+ "json".equals(contentType.subtype())) {
if (body == null) {
bodyContent = "{}";
} else if (body instanceof String) {
@@ -138,8 +137,7 @@ private static RequestBody buildRequestBody(Object body, RestHeaders headers) {
} else {
bodyContent = String.valueOf(body);
}
- RequestBody requestBody = RequestBody.create(bodyContent.getBytes(),
- MediaType.parse(contentType));
+ RequestBody requestBody = RequestBody.create(bodyContent, contentType);
if (headers != null &&
"gzip".equals(headers.get(RestHeaders.CONTENT_ENCODING))) {
@@ -211,7 +209,7 @@ private OkHttpClient buildOkHttpClient(RestClientConfig config) {
}
// ssl
- configSsl(builder, this.baseUrl, config.getTrustStoreFile(),
+ configSsl(builder, config.getTrustStoreFile(),
config.getTrustStorePassword());
// Execute builder callback before builder.build() for user configs
@@ -233,7 +231,7 @@ private OkHttpClient buildOkHttpClient(RestClientConfig config) {
}
@SneakyThrows
- private void configSsl(OkHttpClient.Builder builder, String url, String trustStoreFile,
+ private void configSsl(OkHttpClient.Builder builder, String trustStoreFile,
String trustStorePass) {
if (StringUtils.isBlank(trustStoreFile) || StringUtils.isBlank(trustStorePass)) {
return;
@@ -244,8 +242,7 @@ private void configSsl(OkHttpClient.Builder builder, String url, String trustSto
sslContext.init(null, new TrustManager[]{trustManager}, null);
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
- builder.sslSocketFactory(sslSocketFactory, trustManager)
- .hostnameVerifier(new HostNameVerifier(url));
+ builder.sslSocketFactory(sslSocketFactory, trustManager);
}
@Override
@@ -465,26 +462,20 @@ private X509TrustManager trustManagerForCertificates(String trustStoreFile,
return (X509TrustManager) trustManagers[0];
}
+ /**
+ * Compatibility wrapper using the same certificate identity verification as OkHttp.
+ */
public static class HostNameVerifier implements HostnameVerifier {
- private final String url;
+ private static final HostnameVerifier DEFAULT_VERIFIER = new OkHttpClient().hostnameVerifier();
public HostNameVerifier(String url) {
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
- url = "http://" + url;
- }
- url = URI.create(url).getHost();
- this.url = url;
+ // Keep the public constructor; a URL is not evidence of the peer's identity.
}
@Override
public boolean verify(String hostname, SSLSession session) {
- if (!this.url.isEmpty() && this.url.endsWith(hostname)) {
- return true;
- } else {
- HostnameVerifier verifier = HttpsURLConnection.getDefaultHostnameVerifier();
- return verifier.verify(hostname, session);
- }
+ return DEFAULT_VERIFIER.verify(hostname, session);
}
}
}
diff --git a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Client.java b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Client.java
similarity index 86%
rename from hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Client.java
rename to hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Client.java
index b08d6327d1..e0557ebc21 100644
--- a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Client.java
+++ b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Client.java
@@ -32,4 +32,8 @@ default T serviceProxy(String graph, Class clazz) {
}
void removeAllServiceProxy();
+
+ default boolean removeServiceProxy(String graph, String interfaceId, Object proxy) {
+ throw new UnsupportedOperationException("Removing an individual RPC proxy is not supported");
+ }
}
diff --git a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Server.java b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Server.java
similarity index 85%
rename from hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Server.java
rename to hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Server.java
index cd0290900e..0c9eab15a8 100644
--- a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Server.java
+++ b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/rpc/RpcServiceConfig4Server.java
@@ -25,5 +25,9 @@ public interface RpcServiceConfig4Server {
void removeService(String serviceId);
+ default boolean removeService(String serviceId, Object serviceImpl) {
+ throw new UnsupportedOperationException("Removing an RPC service by implementation is not supported");
+ }
+
void removeAllService();
}
diff --git a/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/util/RocksDBRuntime.java b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/util/RocksDBRuntime.java
new file mode 100644
index 0000000000..d76f1aa84e
--- /dev/null
+++ b/hugegraph-commons/hugegraph-common/src/main/java/org/apache/hugegraph/util/RocksDBRuntime.java
@@ -0,0 +1,68 @@
+/*
+ * 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.util;
+
+import java.net.URL;
+import java.security.CodeSource;
+
+/** Match configuration to the JNI Java classes selected by the launcher. */
+public final class RocksDBRuntime {
+
+ private RocksDBRuntime() {
+ }
+
+ private static URL origin(Class> runtime) {
+ CodeSource source = runtime.getProtectionDomain().getCodeSource();
+ if (source == null || source.getLocation() == null) {
+ throw new IllegalStateException("Cannot verify RocksDB runtime origin for " + runtime.getName() +
+ "; use a class loader with a CodeSource location");
+ }
+ return source.getLocation();
+ }
+
+ public static void verify(String configuredProvider) {
+ if (!"rocksdb".equals(configuredProvider) && !"topling".equals(configuredProvider)) {
+ throw new IllegalArgumentException("Invalid rocksdb.provider: " + configuredProvider);
+ }
+ ClassLoader loader = Thread.currentThread().getContextClassLoader();
+ try {
+ Class> rocks;
+ try {
+ rocks = Class.forName("org.rocksdb.RocksDB", false, loader);
+ } catch (ClassNotFoundException contextLoaderMiss) {
+ loader = RocksDBRuntime.class.getClassLoader();
+ rocks = Class.forName("org.rocksdb.RocksDB", false, loader);
+ }
+ URL origin = origin(rocks);
+ boolean topling = false;
+ try {
+ Class> marker = Class.forName("org.rocksdb.SidePluginRepo", false, loader);
+ topling = origin.equals(origin(marker));
+ } catch (ClassNotFoundException standardRuntime) {
+ // Standard RocksDB has no Topling marker class.
+ }
+ if (topling != "topling".equals(configuredProvider)) {
+ throw new IllegalStateException("Configured rocksdb.provider=" + configuredProvider +
+ " conflicts with selected RocksDB Java runtime: " + origin +
+ "; match the launcher opt-in and business configuration");
+ }
+ } catch (ClassNotFoundException e) {
+ throw new IllegalStateException("RocksDB Java runtime is missing", e);
+ }
+ }
+}
diff --git a/hugegraph-commons/hugegraph-common/src/main/resources/version.properties b/hugegraph-commons/hugegraph-common/src/main/resources/version.properties
index 8d48ef39ca..c62e18ef71 100644
--- a/hugegraph-commons/hugegraph-common/src/main/resources/version.properties
+++ b/hugegraph-commons/hugegraph-common/src/main/resources/version.properties
@@ -20,4 +20,4 @@ Version=${revision}
ApiVersion=0.72
ApiCheckBeginVersion=1.0
ApiCheckEndVersion=2.0
-VersionInBash=1.7.0
+VersionInBash=1.8.0
diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/testutil/AssertTest.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/testutil/AssertTest.java
index 53f60247e9..21f935aa0a 100644
--- a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/testutil/AssertTest.java
+++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/testutil/AssertTest.java
@@ -422,8 +422,6 @@ public void testAssertContains() {
Assert.assertThrows(NullPointerException.class, () -> {
Assert.assertContains(null, "null");
- }, e -> {
- Assert.assertNull(e.getMessage());
});
}
diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
index 4f5fd0728f..f876e9e0c7 100644
--- a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
+++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
@@ -19,11 +19,15 @@
import org.apache.hugegraph.testutil.AssertTest;
import org.apache.hugegraph.testutil.WhiteboxTest;
+import org.apache.hugegraph.unit.auth.TokenGeneratorTest;
import org.apache.hugegraph.unit.config.HugeConfigTest;
+import org.apache.hugegraph.unit.config.HugeConfigValidationTest;
import org.apache.hugegraph.unit.config.OptionSpaceTest;
+import org.apache.hugegraph.unit.config.TypedOptionTest;
import org.apache.hugegraph.unit.event.EventHubTest;
import org.apache.hugegraph.unit.rest.AbstractRestClientTest;
import org.apache.hugegraph.unit.version.VersionTest;
+import org.apache.hugegraph.unit.util.RocksDBRuntimeTest;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
@@ -50,6 +54,7 @@
import org.apache.hugegraph.unit.license.MachineInfoTest;
import org.apache.hugegraph.unit.perf.PerfUtilTest;
import org.apache.hugegraph.unit.perf.StopwatchTest;
+import org.apache.hugegraph.unit.rest.HostNameVerifierTest;
import org.apache.hugegraph.unit.rest.RestClientTest;
import org.apache.hugegraph.unit.rest.RestResultTest;
import org.apache.hugegraph.unit.util.BytesTest;
@@ -71,6 +76,7 @@
@RunWith(Suite.class)
@Suite.SuiteClasses({
+ RocksDBRuntimeTest.class,
LockManagerTest.class,
LockGroupTest.class,
AtomicLockTest.class,
@@ -78,8 +84,11 @@
RowLockTest.class,
PausableScheduledThreadPoolTest.class,
+ TokenGeneratorTest.class,
HugeConfigTest.class,
+ HugeConfigValidationTest.class,
OptionSpaceTest.class,
+ TypedOptionTest.class,
SafeDateFormatTest.class,
BarrierEventTest.class,
EventHubTest.class,
@@ -87,6 +96,7 @@
StopwatchTest.class,
AbstractRestClientTest.class,
RestClientTest.class,
+ HostNameVerifierTest.class,
RestResultTest.class,
VersionTest.class,
diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/auth/TokenGeneratorTest.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/auth/TokenGeneratorTest.java
new file mode 100644
index 0000000000..a047e00e68
--- /dev/null
+++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/auth/TokenGeneratorTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.auth;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.hugegraph.auth.AuthConstant;
+import org.apache.hugegraph.auth.TokenGenerator;
+import org.apache.hugegraph.testutil.Assert;
+import org.junit.Test;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.security.WeakKeyException;
+
+public class TokenGeneratorTest {
+
+ private static final String SECRET = "01234567890123456789012345678901";
+
+ @Test
+ public void testCreateAndVerifyClaims() {
+ TokenGenerator generator = new TokenGenerator(SECRET);
+ Map payload = new HashMap<>();
+ payload.put(AuthConstant.TOKEN_USER_NAME, "alice");
+ payload.put(AuthConstant.TOKEN_USER_ID, "1234");
+ long before = System.currentTimeMillis();
+ Claims claims = generator.verify(generator.create(payload, 60000L));
+ long after = System.currentTimeMillis();
+
+ Assert.assertEquals("alice", claims.get(AuthConstant.TOKEN_USER_NAME));
+ Assert.assertEquals("1234", claims.get(AuthConstant.TOKEN_USER_ID));
+ Assert.assertFalse(claims.containsKey(AuthConstant.TOKEN_USER_PASSWORD));
+ // JWT timestamps have second precision.
+ Assert.assertTrue(claims.getExpiration().getTime() >= before + 59000L);
+ Assert.assertTrue(claims.getExpiration().getTime() <= after + 60000L);
+ }
+
+ @Test
+ public void testReadLegacyHs256Token() {
+ // Fixed claims and independent HMAC-SHA256 signature, not this class's writer.
+ String token = "eyJhbGciOiJIUzI1NiJ9." +
+ "eyJ1c2VyX25hbWUiOiJsZWdhY3ktdXNlciIsInVzZXJfaWQiOiIxMjM0IiwiZXhwIjo0MTAyNDQ0ODAwfQ." +
+ "t2b5xSruyIrZ8c45HOe6OhJt390rm_fdbvft1JLyRTs";
+ Claims claims = new TokenGenerator(SECRET).verify(token);
+ Assert.assertEquals("legacy-user", claims.get(AuthConstant.TOKEN_USER_NAME));
+ Assert.assertEquals("1234", claims.get(AuthConstant.TOKEN_USER_ID));
+ Assert.assertEquals(4102444800000L, claims.getExpiration().getTime());
+ }
+
+ @Test
+ public void testRejectExpiredToken() {
+ TokenGenerator generator = new TokenGenerator(SECRET);
+ String token = generator.create(Collections.singletonMap("user_name", "alice"),
+ -60000L);
+ Assert.assertThrows(ExpiredJwtException.class, () -> generator.verify(token));
+ }
+
+ @Test
+ public void testRejectWrongKeyAndMalformedTokens() {
+ TokenGenerator generator = new TokenGenerator(SECRET);
+ String token = generator.create(Collections.singletonMap("user_name", "alice"),
+ 60000L);
+ TokenGenerator other = new TokenGenerator("abcdefghijklmnopqrstuvwxyz012345");
+ Assert.assertThrows(JwtException.class, () -> other.verify(token));
+ Assert.assertThrows(JwtException.class, () -> generator.verify("not-a-token"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> generator.verify(""));
+ }
+
+ @Test
+ public void testRejectUnsignedToken() {
+ TokenGenerator generator = new TokenGenerator(SECRET);
+ String token = generator.create(Collections.singletonMap("user_name", "alice"),
+ 60000L);
+ String unsigned = "eyJhbGciOiJub25lIn0." + token.split("\\.")[1] + ".";
+ Assert.assertThrows(JwtException.class, () -> generator.verify(unsigned));
+ }
+
+ @Test
+ public void testRejectWeakKey() {
+ Assert.assertThrows(WeakKeyException.class, () -> new TokenGenerator("short-key"));
+ }
+}
diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/HugeConfigValidationTest.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/HugeConfigValidationTest.java
new file mode 100644
index 0000000000..a9e4044f84
--- /dev/null
+++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/HugeConfigValidationTest.java
@@ -0,0 +1,239 @@
+/*
+ * 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.config;
+
+import static org.apache.hugegraph.config.OptionChecker.disallowEmpty;
+import static org.apache.hugegraph.config.OptionChecker.rangeInt;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.configuration2.Configuration;
+import org.apache.hugegraph.config.ConfigException;
+import org.apache.hugegraph.config.ConfigListOption;
+import org.apache.hugegraph.config.ConfigOption;
+import org.apache.hugegraph.config.HugeConfig;
+import org.apache.hugegraph.config.OptionHolder;
+import org.apache.hugegraph.config.OptionSpace;
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.unit.BaseUnitTest;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+public class HugeConfigValidationTest extends BaseUnitTest {
+
+ @BeforeClass
+ public static void init() {
+ OptionSpace.register("validation-test", ValidationOptions.class.getName());
+ }
+
+ @Test
+ public void testUrlOptionAddsMissingScheme() {
+ HugeConfig config = configOf(ValidationOptions.httpUrl.name(), "127.0.0.1:8080");
+ Assert.assertEquals("http://127.0.0.1:8080", config.get(ValidationOptions.httpUrl));
+
+ // Surrounding whitespace is trimmed before the scheme is prefixed
+ config = configOf(ValidationOptions.httpUrl.name(), " graph.local:8080/api ");
+ Assert.assertEquals("http://graph.local:8080/api", config.get(ValidationOptions.httpUrl));
+
+ // A configured scheme which already ends with "://" isn't doubled
+ config = configOf(ValidationOptions.httpsUrl.name(), "graph.local");
+ Assert.assertEquals("https://graph.local", config.get(ValidationOptions.httpsUrl));
+ }
+
+ @Test
+ public void testUrlOptionKeepsExistingScheme() {
+ HugeConfig config = configOf(ValidationOptions.httpUrl.name(), "https://graph.local:8443");
+ Assert.assertEquals("https://graph.local:8443", config.get(ValidationOptions.httpUrl));
+
+ // Only the scheme is lower-cased, host and path keep their case
+ config = configOf(ValidationOptions.httpUrl.name(), "HTTPS://Graph.Local/Path");
+ Assert.assertEquals("https://Graph.Local/Path", config.get(ValidationOptions.httpUrl));
+ }
+
+ @Test
+ public void testUrlNormalizationSkipsDefaultAndPlainOptions() {
+ HugeConfig config = configOf(ValidationOptions.plainUrl.name(), "127.0.0.1:8080");
+ Assert.assertEquals("127.0.0.1:8080", config.get(ValidationOptions.plainUrl));
+ // Default values are returned as declared and never rewritten
+ Assert.assertEquals("localhost:8080", config.get(ValidationOptions.httpUrl));
+ Assert.assertTrue(ValidationOptions.httpUrl.needsUrlNormalization());
+ Assert.assertEquals("http", ValidationOptions.httpUrl.getDefaultScheme());
+ Assert.assertFalse(ValidationOptions.plainUrl.needsUrlNormalization());
+ Assert.assertNull(ValidationOptions.plainUrl.getDefaultScheme());
+ }
+
+ @Test
+ public void testConstructWithInvalidSource() {
+ Assert.assertThrows(ConfigException.class, () -> {
+ new HugeConfig((Map) null);
+ }, e -> {
+ Assert.assertContains("The property map is null", e.getMessage());
+ });
+ Assert.assertThrows(ConfigException.class, () -> {
+ new HugeConfig((Configuration) null);
+ }, e -> {
+ Assert.assertContains("The config object is null", e.getMessage());
+ });
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ new HugeConfig("");
+ }, e -> {
+ Assert.assertContains("The config path can't be empty", e.getMessage());
+ });
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ new HugeConfig("target/not-exist-" + System.nanoTime() + ".properties");
+ }, e -> {
+ Assert.assertContains("Please specify a proper config file", e.getMessage());
+ });
+ // A directory is not a config file either
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ new HugeConfig("src/test");
+ });
+ }
+
+ @Test
+ public void testStringValuesAreParsedAndChecked() {
+ HugeConfig config = configOf(ValidationOptions.port.name(), "8182");
+ Assert.assertEquals(8182, config.get(ValidationOptions.port));
+
+ Assert.assertThrows(ConfigException.class, () -> {
+ configOf(ValidationOptions.port.name(), "70000");
+ }, e -> {
+ Assert.assertContains("Invalid option value for 'validation.port': 70000",
+ e.getMessage());
+ });
+ Assert.assertThrows(ConfigException.class, () -> {
+ configOf(ValidationOptions.port.name(), "http");
+ }, e -> {
+ Assert.assertContains("expect 'Integer' type", e.getMessage());
+ });
+ Assert.assertThrows(ConfigException.class, () -> {
+ configOf(ValidationOptions.name.name(), " ");
+ });
+ }
+
+ @Test
+ public void testNonStringValueWithWrongType() {
+ Map options = new HashMap<>();
+ options.put(ValidationOptions.port.name(), 8182L);
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ new HugeConfig(options);
+ }, e -> {
+ Assert.assertContains("Invalid value for key 'validation.port': '8182'",
+ e.getMessage());
+ });
+
+ options.put(ValidationOptions.port.name(), 8182);
+ Assert.assertEquals(8182, new HugeConfig(options).get(ValidationOptions.port));
+ }
+
+ @Test
+ public void testUnregisteredOptionIsKeptAsRawValue() {
+ HugeConfig config = configOf("validation.unregistered", "raw-value");
+ Assert.assertEquals("raw-value", config.getProperty("validation.unregistered"));
+ Assert.assertEquals("default-name", config.get(ValidationOptions.name));
+ }
+
+ @Test
+ public void testGetMapWithInvalidEntry() {
+ HugeConfig config = configOf(ValidationOptions.pairs.name(), "[k1: v1, k2:a:b]");
+ Assert.assertEquals(ImmutableMap.of("k1", "v1", "k2", "a:b"),
+ config.getMap(ValidationOptions.pairs));
+
+ HugeConfig invalid = configOf(ValidationOptions.pairs.name(), "[k1:v1, k2]");
+ Assert.assertThrows(IllegalStateException.class, () -> {
+ invalid.getMap(ValidationOptions.pairs);
+ }, e -> {
+ Assert.assertContains("Invalid option format for 'validation.pairs': k2",
+ e.getMessage());
+ Assert.assertContains("expect KEY:VALUE", e.getMessage());
+ });
+ }
+
+ private static HugeConfig configOf(String key, Object value) {
+ Map options = new HashMap<>();
+ options.put(key, value);
+ return new HugeConfig(options);
+ }
+
+ public static class ValidationOptions extends OptionHolder {
+
+ private static volatile ValidationOptions instance;
+
+ public static synchronized ValidationOptions instance() {
+ if (instance == null) {
+ instance = new ValidationOptions();
+ instance.registerOptions();
+ }
+ return instance;
+ }
+
+ public static final ConfigOption httpUrl =
+ new ConfigOption<>(
+ "validation.http_url",
+ "url option normalized with http scheme",
+ disallowEmpty(),
+ "localhost:8080"
+ ).withUrlNormalization("http");
+
+ public static final ConfigOption httpsUrl =
+ new ConfigOption<>(
+ "validation.https_url",
+ "url option normalized with https scheme",
+ disallowEmpty(),
+ "localhost:8443"
+ ).withUrlNormalization("https://");
+
+ public static final ConfigOption plainUrl =
+ new ConfigOption<>(
+ "validation.plain_url",
+ "url option without normalization",
+ disallowEmpty(),
+ "localhost:8080"
+ );
+
+ public static final ConfigOption port =
+ new ConfigOption<>(
+ "validation.port",
+ "port option",
+ rangeInt(1, 65535),
+ 8080
+ );
+
+ public static final ConfigOption name =
+ new ConfigOption<>(
+ "validation.name",
+ "non-empty name option",
+ disallowEmpty(),
+ "default-name"
+ );
+
+ public static final ConfigListOption pairs =
+ new ConfigListOption<>(
+ "validation.pairs",
+ false,
+ "key value pairs",
+ null,
+ String.class,
+ ImmutableList.of()
+ );
+ }
+}
diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/TypedOptionTest.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/TypedOptionTest.java
new file mode 100644
index 0000000000..c962329b1b
--- /dev/null
+++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/TypedOptionTest.java
@@ -0,0 +1,278 @@
+/*
+ * 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.config;
+
+import static org.apache.hugegraph.config.OptionChecker.allowValues;
+import static org.apache.hugegraph.config.OptionChecker.disallowEmpty;
+import static org.apache.hugegraph.config.OptionChecker.inValues;
+import static org.apache.hugegraph.config.OptionChecker.nonNegativeInt;
+import static org.apache.hugegraph.config.OptionChecker.positiveInt;
+import static org.apache.hugegraph.config.OptionChecker.rangeDouble;
+import static org.apache.hugegraph.config.OptionChecker.rangeInt;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hugegraph.config.ConfigConvOption;
+import org.apache.hugegraph.config.ConfigException;
+import org.apache.hugegraph.config.ConfigListConvOption;
+import org.apache.hugegraph.config.ConfigListOption;
+import org.apache.hugegraph.config.ConfigOption;
+import org.apache.hugegraph.testutil.Assert;
+import org.junit.Test;
+
+import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
+
+public class TypedOptionTest {
+
+ @Test
+ public void testParseConvertNumbersAndBooleans() {
+ ConfigOption intOption = new ConfigOption<>(
+ "typed.int", "int option", rangeInt(1, 100), 10);
+ Assert.assertEquals(42, intOption.parseConvert("42"));
+ Assert.assertEquals(1, intOption.parseConvert("1"));
+ Assert.assertEquals(100, intOption.parseConvert("100"));
+
+ Assert.assertThrows(ConfigException.class, () -> {
+ intOption.parseConvert("forty-two");
+ }, e -> {
+ Assert.assertContains("Invalid type of value 'forty-two' for option 'typed.int'",
+ e.getMessage());
+ Assert.assertContains("expect 'Integer' type", e.getMessage());
+ });
+ Assert.assertThrows(ConfigException.class, () -> {
+ intOption.parseConvert("101");
+ }, e -> {
+ Assert.assertContains("Invalid option value for 'typed.int': 101", e.getMessage());
+ });
+ Assert.assertThrows(ConfigException.class, () -> intOption.parseConvert("0"));
+
+ ConfigOption longOption = new ConfigOption<>(
+ "typed.long", "long option", null, 1L);
+ Assert.assertEquals(Long.MAX_VALUE, longOption.parseConvert("9223372036854775807"));
+ Assert.assertThrows(ConfigException.class, () -> {
+ longOption.parseConvert("9223372036854775808");
+ });
+
+ ConfigOption doubleOption = new ConfigOption<>(
+ "typed.double", "double option", rangeDouble(0D, 1D), 0.5D);
+ Assert.assertEquals(0.25D, doubleOption.parseConvert("0.25"));
+ Assert.assertThrows(ConfigException.class, () -> doubleOption.parseConvert("1.01"));
+ Assert.assertThrows(ConfigException.class, () -> doubleOption.parseConvert("half"));
+
+ ConfigOption boolOption = new ConfigOption<>(
+ "typed.bool", "bool option", disallowEmpty(), false);
+ Assert.assertEquals(true, boolOption.parseConvert("true"));
+ Assert.assertEquals(false, boolOption.parseConvert("false"));
+ Assert.assertThrows(ConfigException.class, () -> {
+ boolOption.parseConvert("maybe");
+ }, e -> {
+ Assert.assertContains("expect 'Boolean' type", e.getMessage());
+ });
+ }
+
+ @Test
+ public void testParseConvertStringWithChecker() {
+ ConfigOption option = new ConfigOption<>(
+ "typed.choice", "choice option",
+ allowValues("rocksdb", "hstore"), "rocksdb");
+ Assert.assertEquals("hstore", option.parseConvert("hstore"));
+ // Values are matched exactly, no case folding or trimming
+ Assert.assertThrows(ConfigException.class, () -> {
+ option.parseConvert("RocksDB");
+ }, e -> {
+ Assert.assertContains("Invalid option value for 'typed.choice': RocksDB",
+ e.getMessage());
+ });
+ Assert.assertThrows(ConfigException.class, () -> option.parseConvert(" hstore"));
+
+ ConfigOption nonEmpty = new ConfigOption<>(
+ "typed.non_empty", "non-empty option", disallowEmpty(), "value");
+ Assert.assertEquals(" value ", nonEmpty.parseConvert(" value "));
+ Assert.assertThrows(ConfigException.class, () -> nonEmpty.parseConvert(""));
+ Assert.assertThrows(ConfigException.class, () -> nonEmpty.parseConvert(" "));
+ }
+
+ @Test
+ public void testParseConvertClass() {
+ ConfigOption option = new ConfigOption<>(
+ "typed.class", "class option", null, Object.class);
+ Assert.assertEquals(String.class, option.parseConvert("java.lang.String"));
+ // Accepts the format produced by Class.toString()
+ Assert.assertEquals(String.class, option.parseConvert(String.class.toString()));
+
+ Assert.assertThrows(ConfigException.class, () -> {
+ option.parseConvert("org.apache.hugegraph.NoSuchClass");
+ }, e -> {
+ Assert.assertContains("Failed to parse Class from String " +
+ "'org.apache.hugegraph.NoSuchClass'", e.getMessage());
+ Assert.assertInstanceOf(ClassNotFoundException.class, e.getCause());
+ });
+ }
+
+ @Test
+ public void testConstructWithInvalidTypeOrDefault() {
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ new ConfigOption<>("typed.map", "map option", new HashMap());
+ }, e -> {
+ Assert.assertContains("doesn't belong to acceptable type set", e.getMessage());
+ });
+
+ Assert.assertThrows(NullPointerException.class, () -> {
+ new ConfigOption<>("typed.null", false, "null default", null, String.class, null);
+ }, e -> {
+ Assert.assertContains("'value' of 'typed.null' can't be null", e.getMessage());
+ });
+
+ Assert.assertThrows(NullPointerException.class, () -> {
+ new ConfigOption<>(null, false, "no name", null, String.class, "v");
+ });
+ }
+
+ @Test
+ public void testListOptionParse() {
+ ConfigListOption option = new ConfigListOption<>(
+ "typed.list", "int list", disallowEmpty(), 1, 2);
+ Assert.assertEquals(ImmutableList.of(1, 2), option.defaultValue());
+ Assert.assertEquals(ImmutableList.of(3, 4, 5), option.parseConvert("[3, 4 ,5]"));
+ Assert.assertEquals(ImmutableList.of(7), option.parseConvert("7"));
+
+ Assert.assertThrows(ConfigException.class, () -> {
+ option.parseConvert("[1, two]");
+ }, e -> {
+ Assert.assertContains("Invalid type of value 'two'", e.getMessage());
+ });
+ // An empty list literal yields one blank element, which isn't a valid integer
+ Assert.assertThrows(ConfigException.class, () -> option.parseConvert("[]"));
+ }
+
+ @Test
+ public void testListOptionWithInValuesChecker() {
+ ConfigListOption option = new ConfigListOption<>(
+ "typed.list_in", "string list", inValues("a", "b", "c"), "a");
+ Assert.assertEquals(ImmutableList.of("c", "a"), option.parseConvert("[c, a]"));
+ Assert.assertThrows(ConfigException.class, () -> {
+ option.parseConvert("[a, d]");
+ }, e -> {
+ Assert.assertContains("Invalid option value for 'typed.list_in'", e.getMessage());
+ });
+
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ new ConfigListOption("typed.list_empty", false, "empty list",
+ null, null, Collections.emptyList());
+ }, e -> {
+ Assert.assertContains("Element class can't be null", e.getMessage());
+ });
+ }
+
+ @Test
+ public void testConvOptionChecksRawValueBeforeConvert() {
+ ConfigConvOption option = new ConfigConvOption<>(
+ "typed.unit", "time unit", allowValues("SECONDS", "MINUTES"),
+ TimeUnit::valueOf, "SECONDS");
+ Assert.assertEquals(TimeUnit.SECONDS, option.defaultValue());
+ Assert.assertEquals(TimeUnit.MINUTES, option.parseConvert("MINUTES"));
+ // HOURS is a valid TimeUnit but is rejected by the raw value checker
+ Assert.assertThrows(ConfigException.class, () -> option.parseConvert("HOURS"));
+
+ ConfigConvOption unchecked = new ConfigConvOption<>(
+ "typed.unit_unchecked", "time unit", null, TimeUnit::valueOf, "SECONDS");
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ unchecked.parseConvert("WEEKS");
+ });
+
+ Assert.assertThrows(NullPointerException.class, () -> {
+ new ConfigConvOption("typed.unit_null", "time unit",
+ null, null, "SECONDS");
+ }, e -> {
+ Assert.assertContains("'convert' can't be null", e.getMessage());
+ });
+ }
+
+ @Test
+ public void testListConvOptionParse() {
+ ConfigListConvOption option = new ConfigListConvOption<>(
+ "typed.units", "time units", disallowEmpty(), TimeUnit::valueOf,
+ "SECONDS", "DAYS");
+ Assert.assertEquals(ImmutableList.of(TimeUnit.SECONDS, TimeUnit.DAYS),
+ option.defaultValue());
+ Assert.assertEquals(ImmutableList.of(TimeUnit.HOURS, TimeUnit.MINUTES),
+ option.parseConvert("[HOURS, MINUTES]"));
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ option.parseConvert("[HOURS, FORTNIGHTS]");
+ });
+ }
+
+ @Test
+ public void testOptionCheckerBoundariesAndNulls() {
+ Predicate range = rangeInt(-1, 1);
+ Assert.assertTrue(range.apply(-1));
+ Assert.assertTrue(range.apply(1));
+ Assert.assertFalse(range.apply(-2));
+ Assert.assertFalse(range.apply(2));
+ Assert.assertFalse(range.apply(null));
+
+ Predicate doubles = rangeDouble(0.1D, 0.2D);
+ Assert.assertTrue(doubles.apply(0.1D));
+ Assert.assertTrue(doubles.apply(0.2D));
+ Assert.assertFalse(doubles.apply(0.20001D));
+ Assert.assertFalse(doubles.apply(null));
+
+ Predicate positive = positiveInt();
+ Assert.assertTrue(positive.apply(1L));
+ Assert.assertFalse(positive.apply(0L));
+ Assert.assertFalse(positive.apply(null));
+
+ Predicate nonNegative = nonNegativeInt();
+ Assert.assertTrue(nonNegative.apply(0L));
+ Assert.assertFalse(nonNegative.apply(-1L));
+ Assert.assertFalse(nonNegative.apply(null));
+
+ Predicate allowed = allowValues("x", "y");
+ Assert.assertTrue(allowed.apply("y"));
+ Assert.assertFalse(allowed.apply("z"));
+ Assert.assertFalse(allowed.apply(null));
+
+ Predicate> in = inValues("x", "y");
+ Assert.assertTrue(in.apply(ImmutableList.of()));
+ Assert.assertTrue(in.apply(ImmutableList.of("x", "x", "y")));
+ Assert.assertFalse(in.apply(ImmutableList.of("x", "z")));
+ Assert.assertFalse(in.apply(null));
+ }
+
+ @Test
+ public void testDisallowEmptyChecker() {
+ Predicate
@@ -124,7 +124,6 @@
org.jacocojacoco-maven-plugin
- 0.8.2pre-unit-test
diff --git a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcConsumerConfig.java b/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcConsumerConfig.java
index 9459ac516a..96819072e9 100644
--- a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcConsumerConfig.java
+++ b/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcConsumerConfig.java
@@ -20,7 +20,9 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
-import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicReference;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.config.RpcOptions;
@@ -39,9 +41,6 @@
import com.alipay.sofa.rpc.core.response.SofaResponse;
import com.alipay.sofa.rpc.ext.Extension;
import com.alipay.sofa.rpc.ext.ExtensionLoaderFactory;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
public class RpcConsumerConfig implements RpcServiceConfig4Client {
@@ -59,8 +58,8 @@ public RpcConsumerConfig(HugeConfig config, String remoteUrls) {
RpcCommonConfig.initRpcConfigs(config);
this.conf = config;
this.remoteUrls = remoteUrls;
- this.configs = Maps.newHashMap();
- this.bootstraps = Lists.newArrayList();
+ this.configs = new ConcurrentHashMap<>();
+ this.bootstraps = new CopyOnWriteArrayList<>();
}
@Override
@@ -70,28 +69,119 @@ public T serviceProxy(String interfaceId) {
@Override
public T serviceProxy(String graph, String interfaceId) {
- ConsumerConfig config = this.consumerConfig(graph, interfaceId);
- ConsumerBootstrap bootstrap = Bootstraps.from(config);
- this.bootstraps.add(bootstrap);
- return bootstrap.refer();
+ String serviceId = graph == null ? interfaceId : interfaceId + ":" + graph;
+ AtomicReference proxy = new AtomicReference<>();
+ this.configs.compute(serviceId, (key, existing) -> {
+ @SuppressWarnings("unchecked")
+ ConsumerConfig config = existing == null ? this.consumerConfig(graph, interfaceId) :
+ (ConsumerConfig) existing;
+ ConsumerBootstrap bootstrap = Bootstraps.from(config);
+ try {
+ proxy.set(bootstrap.refer());
+ this.bootstraps.add(bootstrap);
+ } catch (RuntimeException | Error failure) {
+ // Retain the cleanup owner even when refer never returned a proxy.
+ this.bootstraps.add(bootstrap);
+ throwFailure(this.cleanup(bootstrap, failure));
+ }
+ return config;
+ });
+ return proxy.get();
+ }
+
+ @Override
+ public boolean removeServiceProxy(String graph, String interfaceId, Object proxy) {
+ String serviceId = graph == null ? interfaceId : interfaceId + ":" + graph;
+ boolean[] removed = {false};
+ this.configs.computeIfPresent(serviceId, (key, config) -> {
+ for (ConsumerBootstrap> bootstrap : this.bootstraps) {
+ if (bootstrap.getConsumerConfig() == config && bootstrap.getProxyIns() == proxy) {
+ Throwable failure = this.cleanup(bootstrap, null);
+ if (failure != null) {
+ throwFailure(failure);
+ }
+ removed[0] = true;
+ break;
+ }
+ }
+ for (ConsumerBootstrap> bootstrap : this.bootstraps) {
+ if (bootstrap.getConsumerConfig() == config) {
+ return config;
+ }
+ }
+ return null;
+ });
+ return removed[0];
}
@Override
public void removeAllServiceProxy() {
+ Throwable failure = null;
for (ConsumerBootstrap> bootstrap : this.bootstraps) {
- bootstrap.unRefer();
+ try {
+ bootstrap.unRefer();
+ } catch (RuntimeException | Error cleanup) {
+ failure = addFailure(failure, cleanup);
+ }
+ }
+ if (failure != null) {
+ throwFailure(failure);
}
}
public void destroy() {
- Set clusters = Sets.newHashSet();
+ Throwable failure = null;
for (ConsumerBootstrap> bootstrap : this.bootstraps) {
+ failure = this.cleanup(bootstrap, failure);
+ }
+ if (failure != null) {
+ throwFailure(failure);
+ }
+ }
+
+ private Throwable cleanup(ConsumerBootstrap> bootstrap, Throwable failure) {
+ boolean complete = true;
+ try {
bootstrap.unRefer();
- clusters.add(bootstrap.getCluster());
+ } catch (RuntimeException | Error cleanup) {
+ complete = false;
+ failure = addFailure(failure, cleanup);
+ }
+ try {
+ Cluster cluster = bootstrap.getCluster();
+ if (cluster != null) {
+ cluster.destroy();
+ }
+ } catch (RuntimeException | Error cleanup) {
+ complete = false;
+ failure = addFailure(failure, cleanup);
+ }
+ if (complete) {
+ this.bootstraps.remove(bootstrap);
+ }
+ return failure;
+ }
+
+ private static Throwable addFailure(Throwable failure, Throwable cleanup) {
+ if (failure == null) {
+ return cleanup;
+ }
+ if (failure == cleanup) {
+ return failure;
}
- for (Cluster cluster : clusters) {
- cluster.destroy();
+ if (cleanup instanceof Error && !(failure instanceof Error)) {
+ cleanup.addSuppressed(failure);
+ return cleanup;
}
+ failure.addSuppressed(cleanup);
+ return failure;
+ }
+
+ private static void throwFailure(Throwable failure) {
+ if (failure instanceof Error) {
+ throw (Error) failure;
+ }
+ throw (RuntimeException) failure;
}
private ConsumerConfig consumerConfig(String graph,
@@ -103,15 +193,7 @@ private ConsumerConfig consumerConfig(String graph,
serviceId = interfaceId;
}
- @SuppressWarnings("unchecked")
- ConsumerConfig consumerConfig = (ConsumerConfig)
- this.configs.get(serviceId);
- if (consumerConfig != null) {
- return consumerConfig;
- }
-
- assert consumerConfig == null;
- consumerConfig = new ConsumerConfig<>();
+ ConsumerConfig consumerConfig = new ConsumerConfig<>();
HugeConfig conf = this.conf;
String protocol = conf.get(RpcOptions.RPC_PROTOCOL);
@@ -138,7 +220,6 @@ private ConsumerConfig consumerConfig(String graph,
.setSerialization(conf.get(RpcOptions.RPC_SERIALIZATION))
.setLoadBalancer(loadBalancer);
- this.configs.put(serviceId, consumerConfig);
return consumerConfig;
}
diff --git a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcProviderConfig.java b/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcProviderConfig.java
index dafdfcf5a4..3ad955a868 100644
--- a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcProviderConfig.java
+++ b/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcProviderConfig.java
@@ -18,14 +18,15 @@
package org.apache.hugegraph.rpc;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import com.alipay.sofa.rpc.config.ProviderConfig;
+import com.alipay.sofa.rpc.config.ServerConfig;
import org.apache.hugegraph.util.E;
-import com.google.common.collect.Maps;
public class RpcProviderConfig implements RpcServiceConfig4Server {
- private final Map> configs = Maps.newHashMap();
+ private final Map> configs = new ConcurrentHashMap<>();
@Override
public String addService(Class clazz, S serviceImpl) {
@@ -54,27 +55,58 @@ private String addService(String graph,
providerConfig.setInterfaceId(interfaceId)
.setRef(serviceImpl);
- E.checkArgument(!this.configs.containsKey(serviceId),
+ E.checkArgument(this.configs.putIfAbsent(serviceId, providerConfig) == null,
"Not allowed to add service already exist: '%s'",
serviceId);
- this.configs.put(serviceId, providerConfig);
return serviceId;
}
@Override
public void removeService(String serviceId) {
- ProviderConfig> config = this.configs.remove(serviceId);
- E.checkArgument(config != null,
- "The service '%s' doesn't exist", serviceId);
- config.unExport();
+ boolean[] removed = {false};
+ this.configs.computeIfPresent(serviceId, (key, config) -> {
+ config.unExport();
+ removed[0] = true;
+ return null;
+ });
+ E.checkArgument(removed[0], "The service '%s' doesn't exist", serviceId);
}
@Override
- public void removeAllService() {
- for (ProviderConfig> config : this.configs.values()) {
+ public boolean removeService(String serviceId, Object serviceImpl) {
+ boolean[] removed = {false};
+ this.configs.computeIfPresent(serviceId, (key, config) -> {
+ if (config.getRef() != serviceImpl) {
+ return config;
+ }
config.unExport();
+ removed[0] = true;
+ return null;
+ });
+ return removed[0];
+ }
+
+ @Override
+ public void removeAllService() {
+ for (String serviceId : this.configs.keySet()) {
+ this.configs.computeIfPresent(serviceId, (key, config) -> {
+ config.unExport();
+ return null;
+ });
+ }
+ }
+
+ void exportAll(ServerConfig server, int timeout) {
+ for (String serviceId : this.configs.keySet()) {
+ this.configs.computeIfPresent(serviceId, (key, config) -> {
+ // Sofa appends a single server; retain other bindings without adding duplicates.
+ if (config.getServer() == null || config.getServer().stream().noneMatch(bound -> bound == server)) {
+ config.setServer(server);
+ }
+ config.setTimeout(timeout).export();
+ return config;
+ });
}
- this.configs.clear();
}
public Map> configs() {
diff --git a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServer.java b/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServer.java
index 7791ac8cd5..1af841c1c7 100644
--- a/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServer.java
+++ b/hugegraph-commons/hugegraph-rpc/src/main/java/org/apache/hugegraph/rpc/RpcServer.java
@@ -104,11 +104,7 @@ public void exportAll() {
return;
}
int timeout = this.conf.get(RpcOptions.RPC_SERVER_TIMEOUT) * 1000;
- for (ProviderConfig> providerConfig : configs.values()) {
- providerConfig.setServer(this.serverConfig)
- .setTimeout(timeout)
- .export();
- }
+ this.configs.exportAll(this.serverConfig, timeout);
LOG.info("RpcServer started success on port {}", this.port());
}
diff --git a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/BaseUnitTest.java b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/BaseUnitTest.java
index 919ba151f0..5098f3f2ac 100644
--- a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/BaseUnitTest.java
+++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/BaseUnitTest.java
@@ -17,13 +17,22 @@
package org.apache.hugegraph.unit;
+import java.net.URI;
import java.net.URL;
+import java.nio.file.Paths;
+import java.util.concurrent.TimeUnit;
import org.junit.BeforeClass;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.config.OptionSpace;
import org.apache.hugegraph.rpc.RpcServer;
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.testutil.Whitebox;
+
+import com.alipay.sofa.rpc.config.ServerConfig;
+import com.alipay.sofa.rpc.server.Server;
+import io.netty.channel.ChannelFuture;
public class BaseUnitTest {
@@ -39,7 +48,7 @@ protected static HugeConfig config(boolean server) {
protected static HugeConfig config(String type) {
String name = String.format("rpc-%s.properties", type);
URL conf = BaseUnitTest.class.getClassLoader().getResource(name);
- return new HugeConfig(conf.getPath());
+ return new HugeConfig(Paths.get(URI.create(conf.toExternalForm())).toString());
}
protected static void startServer(RpcServer rpcServer) {
@@ -51,6 +60,34 @@ protected static void startServer(RpcServer rpcServer) {
}
protected static void stopServer(RpcServer rpcServer) {
- rpcServer.destroy();
+ closeServer(rpcServer, rpcServer::destroy);
+ }
+
+ protected static void unexportServer(RpcServer rpcServer) {
+ closeServer(rpcServer, rpcServer::unexportAll);
+ }
+
+ protected static ChannelFuture listener(RpcServer rpcServer) {
+ if (!rpcServer.enabled()) {
+ return null;
+ }
+ ServerConfig config = Whitebox.getInternalState(rpcServer, "serverConfig");
+ Server server = config.getServer();
+ if (server == null || !server.isStarted()) {
+ return null;
+ }
+ Object remoting = Whitebox.getInternalState(server, "remotingServer");
+ return Whitebox.getInternalState(remoting, "channelFuture");
+ }
+
+ private static void closeServer(RpcServer rpcServer, Runnable close) {
+ ChannelFuture binding = listener(rpcServer);
+ close.run();
+ if (binding != null) {
+ // Bolt stops asynchronously; the next fixture may reuse this port.
+ ChannelFuture closed = binding.channel().closeFuture();
+ Assert.assertTrue(closed.awaitUninterruptibly(5, TimeUnit.SECONDS));
+ Assert.assertTrue(closed.isSuccess());
+ }
}
}
diff --git a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java
new file mode 100644
index 0000000000..cf2ce94dfe
--- /dev/null
+++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java
@@ -0,0 +1,536 @@
+/*
+ * 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;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.hugegraph.config.ConfigException;
+import org.apache.hugegraph.config.HugeConfig;
+import org.apache.hugegraph.config.RpcOptions;
+import org.apache.hugegraph.rpc.RpcClientProvider;
+import org.apache.hugegraph.rpc.RpcConsumerConfig;
+import org.apache.hugegraph.rpc.RpcProviderConfig;
+import org.apache.hugegraph.rpc.RpcServer;
+import org.apache.hugegraph.testutil.Assert;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.apache.hugegraph.unit.ServerClientTest.HelloService;
+import org.apache.hugegraph.unit.ServerClientTest.HelloServiceImpl;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import com.alipay.sofa.rpc.bootstrap.ConsumerBootstrap;
+import com.alipay.sofa.rpc.bootstrap.DefaultConsumerBootstrap;
+import com.alipay.sofa.rpc.client.Cluster;
+import com.alipay.sofa.rpc.client.FailoverCluster;
+import com.alipay.sofa.rpc.config.ConsumerConfig;
+import com.alipay.sofa.rpc.config.ProviderConfig;
+import com.alipay.sofa.rpc.ext.Extension;
+import com.alipay.sofa.rpc.ext.ExtensionLoaderFactory;
+
+public class RpcConfigTest extends BaseUnitTest {
+
+ private static final String HELLO = HelloService.class.getName();
+
+ private static ConsumerFailure consumerFailure;
+
+ @BeforeClass
+ public static void registerCleanupBootstrap() {
+ ExtensionLoaderFactory.getExtensionLoader(ConsumerBootstrap.class)
+ .loadExtension(CleanupBootstrap.class);
+ ExtensionLoaderFactory.getExtensionLoader(Cluster.class)
+ .loadExtension(CleanupCluster.class);
+ }
+
+ @Test
+ public void testReferFailureRetainsCleanupOwner() {
+ this.assertReferCleanup(new IllegalStateException("refer failure"), false, false);
+ }
+
+ @Test
+ public void testReferErrorPreservedOnSelfSuppression() {
+ this.assertReferCleanup(new AssertionError("refer error"), true, false);
+ }
+
+ @Test
+ public void testCleanupErrorPromotedAboveReferException() {
+ this.assertReferCleanup(new IllegalStateException("refer failure"), false, true);
+ }
+
+ @Test
+ public void testReferFailureWithSuccessfulCleanupReleasesOwner() {
+ RpcConsumerConfig owner = new RpcConsumerConfig(new HugeConfig(Collections.emptyMap()),
+ "127.0.0.1:1");
+ ConsumerFailure control = new ConsumerFailure(owner, new IllegalStateException("refer failure"));
+ control.disarm();
+ try {
+ Assert.assertSame(control.referFailure, captureFailure(() -> owner.serviceProxy(HELLO)));
+ Assert.assertGt(0, control.destroyCalls.get());
+ Assert.assertFalse(control.retained());
+ Assert.assertNull(control.bootstrap.getProxyIns());
+ } finally {
+ control.close();
+ }
+ }
+
+ private void assertReferCleanup(Throwable primary, boolean sameCleanup, boolean fatalCleanup) {
+ RpcConsumerConfig owner = new RpcConsumerConfig(new HugeConfig(Collections.emptyMap()),
+ "127.0.0.1:1");
+ ConsumerFailure control = new ConsumerFailure(owner, primary);
+ control.unreferFailure = sameCleanup ? primary : new IllegalStateException("unrefer failure");
+ if (fatalCleanup) {
+ control.clusterFailure = new AssertionError("cluster error");
+ } else if (sameCleanup) {
+ control.clusterFailure = new AssertionError("secondary cluster error");
+ }
+ try {
+ Throwable thrown = captureFailure(() -> owner.serviceProxy(HELLO));
+ Assert.assertNotNull(control.bootstrap.getProxyIns());
+ Assert.assertSame(fatalCleanup ? control.clusterFailure : primary, thrown);
+ if (fatalCleanup) {
+ Assert.assertSame(primary, thrown.getSuppressed()[0]);
+ }
+ Assert.assertGt(0, control.destroyCalls.get());
+ Assert.assertTrue(control.retained());
+ control.disarm();
+ owner.destroy();
+ Assert.assertFalse(control.retained());
+ Assert.assertNull(control.bootstrap.getProxyIns());
+ } finally {
+ control.close();
+ }
+ }
+
+ @Test
+ public void testClusterCleanupFailureRetainsOwnerForRetry() {
+ RpcConsumerConfig owner = new RpcConsumerConfig(new HugeConfig(Collections.emptyMap()),
+ "127.0.0.1:1");
+ ConsumerFailure control = new ConsumerFailure(owner, new IllegalStateException("refer failure"));
+ control.failUnrefer = false;
+ control.clusterFailure = new IllegalStateException("cluster failure");
+ try {
+ Assert.assertSame(control.referFailure, captureFailure(() -> owner.serviceProxy(HELLO)));
+ // Native unRefer swallows a cluster Exception, so the explicit destroy must also run.
+ Assert.assertGte(2, control.destroyCalls.get());
+ Assert.assertTrue(control.retained());
+ control.disarm();
+ owner.destroy();
+ Assert.assertFalse(control.retained());
+ } finally {
+ control.close();
+ }
+ }
+
+ @Test
+ public void testDestroyContinuesRemainingConsumers() {
+ RpcConsumerConfig owner = new RpcConsumerConfig(new HugeConfig(Collections.emptyMap()),
+ "127.0.0.1:1");
+ ConsumerFailure first = new ConsumerFailure(owner, new IllegalStateException("first refer"));
+ ConsumerFailure second = null;
+ try {
+ captureFailure(() -> owner.serviceProxy(HELLO));
+ second = new ConsumerFailure(owner, new IllegalStateException("second refer"));
+ captureFailure(() -> owner.serviceProxy(HELLO));
+ second.disarm();
+ Assert.assertSame(first.unreferFailure, captureFailure(owner::removeAllServiceProxy));
+ Assert.assertNull(second.bootstrap.getProxyIns());
+ // Unrefer-only cleanup retains owners for the subsequent explicit destroy.
+ Assert.assertTrue(second.retained());
+ Assert.assertSame(first.unreferFailure, captureFailure(owner::destroy));
+ Assert.assertTrue(first.retained());
+ Assert.assertFalse(second.retained());
+ Assert.assertNull(second.bootstrap.getProxyIns());
+ first.disarm();
+ owner.destroy();
+ Assert.assertFalse(first.retained());
+ } finally {
+ first.close();
+ if (second != null) {
+ second.close();
+ }
+ }
+ }
+
+ @Test
+ public void testRemoveConsumerRetainsFailedCleanupOwner() {
+ RpcConsumerConfig owner = new RpcConsumerConfig(new HugeConfig(Collections.emptyMap()),
+ "127.0.0.1:1");
+ ConsumerFailure control = new ConsumerFailure(owner, null);
+ try {
+ HelloService proxy = owner.serviceProxy(HELLO);
+ Assert.assertSame(control.unreferFailure, captureFailure(() -> {
+ owner.removeServiceProxy(null, HELLO, proxy);
+ }));
+ Assert.assertGt(0, control.destroyCalls.get());
+ Assert.assertTrue(control.retained());
+ Map> configs = Whitebox.getInternalState(owner, "configs");
+ Assert.assertSame(control.bootstrap.getConsumerConfig(), configs.get(HELLO));
+ control.disarm();
+ owner.destroy();
+ Assert.assertFalse(control.retained());
+ } finally {
+ control.close();
+ }
+ }
+
+ private static Throwable captureFailure(Runnable action) {
+ try {
+ action.run();
+ } catch (RuntimeException | Error failure) {
+ return failure;
+ }
+ throw new AssertionError("Expected consumer failure");
+ }
+
+ private static final class ConsumerFailure implements AutoCloseable {
+
+ private static final AtomicInteger IDS = new AtomicInteger();
+ private final RpcConsumerConfig owner;
+ private final Throwable referFailure;
+ private final AtomicInteger destroyCalls = new AtomicInteger();
+ private Throwable unreferFailure = new IllegalStateException("unrefer failure");
+ private Throwable clusterFailure;
+ private boolean failUnrefer = true;
+ private CleanupBootstrap bootstrap;
+
+ private ConsumerFailure(RpcConsumerConfig owner, Throwable referFailure) {
+ this.owner = owner;
+ this.referFailure = referFailure;
+ ConsumerConfig config = new ConsumerConfig<>();
+ config.setInterfaceId(HELLO).setUniqueId("cleanup-" + IDS.incrementAndGet())
+ .setProtocol("bolt").setDirectUrl("127.0.0.1:1")
+ .setLazy(true).setCheck(false).setInJVM(false)
+ .setBootstrap("hugegraph_cleanup_test").setCluster("hugegraph_cleanup_test");
+ Map> configs = Whitebox.getInternalState(owner, "configs");
+ configs.put(HELLO, config);
+ consumerFailure = this;
+ }
+
+ private boolean retained() {
+ List> bootstraps = Whitebox.getInternalState(this.owner, "bootstraps");
+ return bootstraps.contains(this.bootstrap);
+ }
+
+ private void disarm() {
+ this.failUnrefer = false;
+ this.clusterFailure = null;
+ }
+
+ @Override
+ public void close() {
+ this.disarm();
+ if (this.bootstrap != null) {
+ this.bootstrap.unRefer();
+ this.bootstrap.getCluster().destroy();
+ }
+ consumerFailure = null;
+ }
+ }
+
+ @Extension("hugegraph_cleanup_test")
+ public static final class CleanupBootstrap extends DefaultConsumerBootstrap {
+
+ private final ConsumerFailure control;
+
+ public CleanupBootstrap(ConsumerConfig config) {
+ super(config);
+ this.control = consumerFailure;
+ this.control.bootstrap = this;
+ }
+
+ @Override
+ public HelloService refer() {
+ HelloService proxy = super.refer();
+ if (this.control.referFailure != null) {
+ throwFailure(this.control.referFailure);
+ }
+ return proxy;
+ }
+
+ @Override
+ public void unRefer() {
+ if (this.control.failUnrefer) {
+ throwFailure(this.control.unreferFailure);
+ }
+ super.unRefer();
+ }
+
+ private static void throwFailure(Throwable failure) {
+ if (failure instanceof Error) {
+ throw (Error) failure;
+ }
+ throw (RuntimeException) failure;
+ }
+ }
+
+ @Extension("hugegraph_cleanup_test")
+ public static final class CleanupCluster extends FailoverCluster {
+
+ private final ConsumerFailure control;
+
+ public CleanupCluster(ConsumerBootstrap> bootstrap) {
+ super(bootstrap);
+ this.control = ((CleanupBootstrap) bootstrap).control;
+ }
+
+ @Override
+ public void destroy() {
+ // Use the native cluster lifecycle; control only the cleanup failure boundary.
+ this.control.destroyCalls.incrementAndGet();
+ if (this.control.clusterFailure != null) {
+ CleanupBootstrap.throwFailure(this.control.clusterFailure);
+ }
+ super.destroy();
+ }
+ }
+
+ @Test
+ public void testExcludeSelfUrl() {
+ Assert.assertEquals("127.0.0.1:8092,127.0.0.1:8093",
+ excludeSelfUrl("127.0.0.1:8091,127.0.0.1:8092;127.0.0.1:8093",
+ "127.0.0.1:8091"));
+ // Order is kept and duplicates are collapsed
+ Assert.assertEquals("b:2,a:1",
+ excludeSelfUrl("b:2,a:1,b:2,self:0", "self:0"));
+ Assert.assertEquals("a:1", excludeSelfUrl("a:1", "self:0"));
+ Assert.assertEquals("", excludeSelfUrl("self:0,self:0", "self:0"));
+ Assert.assertEquals("", excludeSelfUrl("", "self:0"));
+ }
+
+ @Test
+ public void testClientDisabledWhenOnlySelfIsRemote() {
+ Map options = new HashMap<>();
+ options.put(RpcOptions.RPC_SERVER_HOST.name(), "127.0.0.1");
+ options.put(RpcOptions.RPC_SERVER_PORT.name(), "8091");
+ options.put(RpcOptions.RPC_REMOTE_URL.name(), "127.0.0.1:8091");
+ RpcClientProvider selfOnly = new RpcClientProvider(new HugeConfig(options));
+ Assert.assertFalse(selfOnly.enabled());
+ Assert.assertThrows(IllegalArgumentException.class, selfOnly::config, e -> {
+ Assert.assertContains("add an address other than self service", e.getMessage());
+ });
+ // Both are no-ops when the client is disabled
+ selfOnly.unreferAll();
+ selfOnly.destroy();
+
+ options.put(RpcOptions.RPC_REMOTE_URL.name(), "127.0.0.1:8091,127.0.0.1:8092");
+ RpcClientProvider withPeer = new RpcClientProvider(new HugeConfig(options));
+ try {
+ Assert.assertTrue(withPeer.enabled());
+ // Self is dropped from the direct URL the SOFA consumer is built with
+ Assert.assertEquals("127.0.0.1:8092",
+ consumerConfig(withPeer.config(), null, HELLO).getDirectUrl());
+ } finally {
+ withPeer.destroy();
+ }
+ }
+
+ @Test
+ public void testRpcOptionsRejectInvalidValues() {
+ assertInvalidOption(RpcOptions.RPC_PROTOCOL.name(), "grpc");
+ assertInvalidOption(RpcOptions.RPC_SERIALIZATION.name(), "json");
+ assertInvalidOption(RpcOptions.RPC_CLIENT_LOAD_BALANCER.name(), "leastActive");
+ assertInvalidOption(RpcOptions.RPC_SERVER_PORT.name(), "-1");
+ assertInvalidOption(RpcOptions.RPC_SERVER_TIMEOUT.name(), "0");
+ assertInvalidOption(RpcOptions.RPC_CLIENT_RETRIES.name(), "-1");
+ assertInvalidOption(RpcOptions.RPC_CLIENT_READ_TIMEOUT.name(), "ten");
+ assertInvalidOption(RpcOptions.RPC_LOGGER_IMPL.name(), " ");
+
+ Map options = new HashMap<>();
+ options.put(RpcOptions.RPC_SERVER_PORT.name(), "0");
+ options.put(RpcOptions.RPC_CLIENT_RETRIES.name(), "0");
+ options.put(RpcOptions.RPC_CLIENT_LOAD_BALANCER.name(), "roundRobin");
+ HugeConfig config = new HugeConfig(options);
+ Assert.assertEquals(0, config.get(RpcOptions.RPC_SERVER_PORT));
+ Assert.assertEquals(0, config.get(RpcOptions.RPC_CLIENT_RETRIES));
+ Assert.assertEquals("roundRobin", config.get(RpcOptions.RPC_CLIENT_LOAD_BALANCER));
+ }
+
+ @Test
+ public void testProviderServiceIds() {
+ RpcProviderConfig provider = new RpcProviderConfig();
+ String plain = provider.addService(HelloService.class, new HelloServiceImpl());
+ String graph1 = provider.addService("g1", HelloService.class, new HelloServiceImpl());
+ String graph2 = provider.addService("g2", HelloService.class, new HelloServiceImpl());
+ Assert.assertEquals(HELLO, plain);
+ Assert.assertEquals(HELLO + ":g1", graph1);
+ Assert.assertEquals(HELLO + ":g2", graph2);
+ Assert.assertEquals(3, provider.configs().size());
+
+ ProviderConfig> config = provider.configs().get(graph1);
+ Assert.assertEquals(graph1, config.getId());
+ Assert.assertEquals("g1", config.getUniqueId());
+ Assert.assertEquals(HELLO, config.getInterfaceId());
+ Assert.assertEquals("", provider.configs().get(plain).getUniqueId());
+
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ provider.addService("g1", HelloService.class, new HelloServiceImpl());
+ }, e -> {
+ Assert.assertContains("Not allowed to add service already exist: '" +
+ graph1 + "'", e.getMessage());
+ });
+
+ provider.removeService(graph1);
+ Assert.assertFalse(provider.configs().containsKey(graph1));
+ Assert.assertThrows(IllegalArgumentException.class, () -> {
+ provider.removeService(graph1);
+ }, e -> {
+ Assert.assertContains("doesn't exist", e.getMessage());
+ });
+ // A removed id can be registered again
+ Assert.assertEquals(graph1, provider.addService("g1", HelloService.class,
+ new HelloServiceImpl()));
+
+ provider.removeAllService();
+ Assert.assertTrue(provider.configs().isEmpty());
+ }
+
+ @Test
+ public void testConsumerConfigFromOptions() {
+ Map options = new HashMap<>();
+ options.put(RpcOptions.RPC_CLIENT_READ_TIMEOUT.name(), "7");
+ options.put(RpcOptions.RPC_CLIENT_CONNECT_TIMEOUT.name(), "3");
+ options.put(RpcOptions.RPC_CLIENT_RECONNECT_PERIOD.name(), "5");
+ options.put(RpcOptions.RPC_CLIENT_RETRIES.name(), "2");
+ options.put(RpcOptions.RPC_CLIENT_LOAD_BALANCER.name(), "random");
+ String remote = "127.0.0.1:8092,127.0.0.1:8093";
+ RpcConsumerConfig consumer = new RpcConsumerConfig(new HugeConfig(options), remote);
+
+ ConsumerConfig> plain = consumerConfig(consumer, null, HELLO);
+ Assert.assertEquals(HELLO, plain.getInterfaceId());
+ Assert.assertEquals("bolt", plain.getProtocol());
+ Assert.assertEquals("hessian2", plain.getSerialization());
+ Assert.assertEquals(remote, plain.getDirectUrl());
+ Assert.assertEquals(7000, plain.getTimeout());
+ Assert.assertEquals(3000, plain.getConnectTimeout());
+ Assert.assertEquals(5000, plain.getReconnectPeriod());
+ Assert.assertEquals(2, plain.getRetries());
+ Assert.assertEquals("random", plain.getLoadBalancer());
+ Assert.assertEquals("", plain.getUniqueId());
+ Assert.assertNotEquals("fanout", plain.getCluster());
+
+ // Graph scoped consumers broadcast to every provider of that graph
+ ConsumerConfig> graph = consumerConfig(consumer, "g1", HELLO);
+ Assert.assertEquals(HELLO + ":g1", graph.getId());
+ Assert.assertEquals("g1", graph.getUniqueId());
+ Assert.assertEquals("fanout", graph.getCluster());
+
+ consumer.destroy();
+ }
+
+ @Test
+ public void testConsumerConfigCachedThroughServiceProxy() {
+ RpcServer server = new RpcServer(config("server-random"));
+ RpcConsumerConfig consumer = null;
+ try {
+ server.config().addService(HelloService.class, new HelloServiceImpl());
+ server.config().addService("g1", HelloService.class, new HelloServiceImpl());
+ server.config().addService("g2", HelloService.class, new HelloServiceImpl());
+ server.exportAll();
+ consumer = new RpcConsumerConfig(config("client"), server.host() + ":" + server.port());
+ ConsumerConfig> plain = referredConfig(consumer, null);
+ ConsumerConfig> graph = referredConfig(consumer, "g1");
+ Assert.assertSame(plain, referredConfig(consumer, null));
+ Assert.assertSame(graph, referredConfig(consumer, "g1"));
+ Assert.assertNotSame(graph, referredConfig(consumer, "g2"));
+ } finally {
+ if (consumer != null) {
+ consumer.destroy();
+ }
+ stopServer(server);
+ }
+ }
+
+ private static ConsumerConfig> referredConfig(RpcConsumerConfig consumer, String graph) {
+ HelloService proxy = consumer.serviceProxy(graph, HELLO);
+ Assert.assertEquals("hello tom!", proxy.hello("tom"));
+ Map> configs = Whitebox.getInternalState(consumer, "configs");
+ String id = graph == null ? HELLO : HELLO + ":" + graph;
+ Assert.assertNotNull(configs.get(id));
+ return configs.get(id);
+ }
+
+ @Test
+ public void testServerDestroyClosesServices() {
+ RpcServer server = new RpcServer(config("server-random"));
+ Assert.assertTrue(server.enabled());
+ // Host and port are only known once the server is bound
+ Assert.assertNull(server.host());
+ Assert.assertEquals(0, server.port());
+
+ CloseableHelloService closeable = new CloseableHelloService(false);
+ CloseableHelloService failing = new CloseableHelloService(true);
+ server.config().addService(HelloService.class, closeable);
+ server.config().addService("g1", HelloService.class, failing);
+ try {
+ server.exportAll();
+ Assert.assertEquals("127.0.0.1", server.host());
+ Assert.assertGt(0, server.port());
+ } finally {
+ server.destroy();
+ }
+
+ Assert.assertEquals(1, closeable.closed.get());
+ // A failure while closing one service doesn't stop the shutdown
+ Assert.assertEquals(1, failing.closed.get());
+ Assert.assertTrue(server.config().configs().isEmpty());
+ }
+
+ private static String excludeSelfUrl(String rpcUrl, String selfUrl) {
+ return Whitebox.invokeStatic(RpcClientProvider.class, "excludeSelfUrl",
+ rpcUrl, selfUrl);
+ }
+
+ private static ConsumerConfig> consumerConfig(RpcConsumerConfig consumer,
+ String graph, String interfaceId) {
+ return Whitebox.invoke(RpcConsumerConfig.class,
+ new Class>[]{String.class, String.class},
+ "consumerConfig", consumer, graph, interfaceId);
+ }
+
+ private static void assertInvalidOption(String key, String value) {
+ Map options = new HashMap<>();
+ options.put(key, value);
+ Assert.assertThrows(ConfigException.class, () -> {
+ new HugeConfig(options);
+ }, e -> {
+ Assert.assertContains(key, e.getMessage());
+ });
+ }
+
+ public static class CloseableHelloService extends HelloServiceImpl
+ implements AutoCloseable {
+
+ private final boolean failOnClose;
+ private final AtomicInteger closed = new AtomicInteger();
+
+ public CloseableHelloService(boolean failOnClose) {
+ this.failOnClose = failOnClose;
+ }
+
+ @Override
+ public void close() {
+ this.closed.incrementAndGet();
+ if (this.failOnClose) {
+ throw new IllegalStateException("close failure for test");
+ }
+ }
+ }
+}
diff --git a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/ServerClientTest.java b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/ServerClientTest.java
index a1c64c0585..a4c14b07e3 100644
--- a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/ServerClientTest.java
+++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/ServerClientTest.java
@@ -20,6 +20,15 @@
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+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.concurrent.atomic.AtomicInteger;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.rpc.RpcClientProvider;
@@ -36,9 +45,12 @@
import org.junit.Test;
import com.alipay.sofa.rpc.common.RpcOptions;
+import com.alipay.sofa.rpc.config.ProviderConfig;
+import com.alipay.sofa.rpc.config.ServerConfig;
import com.alipay.sofa.rpc.core.exception.SofaRpcException;
import com.alipay.sofa.rpc.core.exception.SofaRpcRuntimeException;
import com.google.common.collect.ImmutableMap;
+import io.netty.channel.ChannelFuture;
public class ServerClientTest extends BaseUnitTest {
@@ -57,7 +69,7 @@ public static void clear() {
rpcClient.destroy();
}
if (rpcServer != null) {
- rpcServer.destroy();
+ stopServer(rpcServer);
}
}
@@ -67,7 +79,45 @@ public void teardown() {
rpcClient.unreferAll();
}
if (rpcServer != null) {
- rpcServer.unexportAll();
+ unexportServer(rpcServer);
+ }
+ }
+
+ @Test
+ public void testUnexportWaitsBeforeReusingListenerPort() throws Exception {
+ rpcServer.config().addService(HelloService.class, new HelloServiceImpl());
+ startServer(rpcServer);
+ ChannelFuture binding = listener(rpcServer);
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ binding.channel().eventLoop().execute(() -> {
+ entered.countDown();
+ try {
+ E.checkState(release.await(5, TimeUnit.SECONDS), "RPC close barrier timed out");
+ } catch (InterruptedException failure) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(failure);
+ }
+ });
+ try {
+ Assert.assertTrue(entered.await(5, TimeUnit.SECONDS));
+ Future> closing = worker.submit(() -> unexportServer(rpcServer));
+ Assert.assertThrows(TimeoutException.class, () -> closing.get(1, TimeUnit.SECONDS));
+ Assert.assertTrue(rpcServer.config().configs().isEmpty());
+ Assert.assertTrue(binding.channel().isOpen());
+ Assert.assertFalse(binding.channel().closeFuture().isDone());
+ release.countDown();
+ closing.get(5, TimeUnit.SECONDS);
+ Assert.assertTrue(binding.channel().closeFuture().isSuccess());
+ rpcServer.config().addService(HelloService.class, new HelloServiceImpl());
+ startServer(rpcServer);
+ HelloService client = rpcClient.config().serviceProxy(HelloService.class);
+ Assert.assertEquals("hello tom!", client.hello("tom"));
+ } finally {
+ release.countDown();
+ worker.shutdownNow();
+ Assert.assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
}
}
@@ -406,118 +456,140 @@ public void testFanoutCallServiceWithError() {
@Test
public void testLoadBalancer() {
- // Init 3 servers
- HugeConfig server3 = config("server3");
- RpcServer rpcServer3 = new RpcServer(server3);
-
- HugeConfig server4 = config("server4");
- RpcServer rpcServer4 = new RpcServer(server4);
-
- HugeConfig server5 = config("server5");
- RpcServer rpcServer5 = new RpcServer(server5);
-
- GraphHelloServiceImpl s3g1 = new GraphHelloServiceImpl("g1");
- GraphHelloServiceImpl s4g1 = new GraphHelloServiceImpl("g1");
- GraphHelloServiceImpl s5g1 = new GraphHelloServiceImpl("g1");
-
- rpcServer3.config().addService(HelloService.class, s3g1);
- rpcServer4.config().addService(HelloService.class, s4g1);
- rpcServer5.config().addService(HelloService.class, s5g1);
-
- startServer(rpcServer3);
- startServer(rpcServer4);
- startServer(rpcServer5);
-
- // Test LB "consistentHash"
- HugeConfig clientLB = config("client-lb");
- RpcClientProvider rpcClientCHash = new RpcClientProvider(clientLB);
- HelloService cHash = rpcClientCHash.config()
- .serviceProxy(HelloService.class);
-
- Assert.assertEquals("g1: load", cHash.echo("load"));
- Assert.assertEquals(16.8, cHash.sum(10, 6.8), 0.00000001d);
- Assert.assertEquals(16.8, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- Assert.assertEquals("g1: load", cHash.echo("load"));
- Assert.assertEquals(16.8, cHash.sum(10, 6.8), 0.00000001d);
- Assert.assertEquals(16.8, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- Assert.assertEquals("g1: load", cHash.echo("load"));
- Assert.assertEquals(16.8, cHash.sum(10, 6.8), 0.00000001d);
- Assert.assertEquals(16.8, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- s3g1.resetResult();
- s4g1.resetResult();
- s5g1.resetResult();
-
- // Test LB "roundRobin"
- String lbKey = org.apache.hugegraph.config.RpcOptions.RPC_CLIENT_LOAD_BALANCER.name();
- clientLB.setProperty(lbKey, "roundRobin");
- RpcClientProvider rpcClientRound = new RpcClientProvider(clientLB);
- HelloService round = rpcClientRound.config()
- .serviceProxy(HelloService.class);
-
- Assert.assertEquals("g1: load", round.echo("load"));
- Assert.assertEquals(1.1, round.sum(1, 0.1), 0.00000001d);
- Assert.assertEquals(1.1, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- Assert.assertEquals("g1: load", round.echo("load"));
- Assert.assertEquals(1.1, round.sum(1, 0.1), 0.00000001d);
- Assert.assertEquals(2.2, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- Assert.assertEquals("g1: load", round.echo("load"));
- Assert.assertEquals(1.1, round.sum(1, 0.1), 0.00000001d);
- Assert.assertEquals(3.3, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- s3g1.resetResult();
- s4g1.resetResult();
- s5g1.resetResult();
-
- // Test LB "random"
- clientLB.setProperty(lbKey, "random");
- RpcClientProvider rpcClientRandom = new RpcClientProvider(clientLB);
- HelloService random = rpcClientRandom.config()
- .serviceProxy(HelloService.class);
-
- Assert.assertEquals("g1: load", random.echo("load"));
- Assert.assertEquals(1.1, random.sum(1, 0.1), 0.00000001d);
- Assert.assertEquals(1.1, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- Assert.assertEquals("g1: load", random.echo("load"));
- Assert.assertEquals(1.1, random.sum(1, 0.1), 0.00000001d);
- double sum = s3g1.result() + s4g1.result() + s5g1.result();
- Assert.assertTrue(2.2 == sum || 1.1 == sum);
-
- Assert.assertEquals("g1: load", random.echo("load"));
- Assert.assertEquals(1.1, random.sum(1, 0.1), 0.00000001d);
- double sum2 = s3g1.result() + s4g1.result() + s5g1.result();
- Assert.assertTrue(sum == sum2 || sum + 1.1 == sum2);
-
- for (int i = 0; i < 9; i++) {
- Assert.assertEquals(1.1, random.sum(1, 0.1), 0.00000001d);
+ List cleanup = new ArrayList<>();
+ Throwable failure = null;
+ try {
+ // Init 3 servers
+ HugeConfig server3 = config("server3");
+ RpcServer rpcServer3 = new RpcServer(server3);
+ cleanup.add(() -> stopServer(rpcServer3));
+
+ HugeConfig server4 = config("server4");
+ RpcServer rpcServer4 = new RpcServer(server4);
+ cleanup.add(() -> stopServer(rpcServer4));
+
+ HugeConfig server5 = config("server5");
+ RpcServer rpcServer5 = new RpcServer(server5);
+ cleanup.add(() -> stopServer(rpcServer5));
+
+ GraphHelloServiceImpl s3g1 = new GraphHelloServiceImpl("g1");
+ GraphHelloServiceImpl s4g1 = new GraphHelloServiceImpl("g1");
+ GraphHelloServiceImpl s5g1 = new GraphHelloServiceImpl("g1");
+
+ rpcServer3.config().addService(HelloService.class, s3g1);
+ rpcServer4.config().addService(HelloService.class, s4g1);
+ rpcServer5.config().addService(HelloService.class, s5g1);
+
+ startServer(rpcServer3);
+ startServer(rpcServer4);
+ startServer(rpcServer5);
+
+ // Test LB "consistentHash"
+ HugeConfig clientLB = config("client-lb");
+ RpcClientProvider rpcClientCHash = new RpcClientProvider(clientLB);
+ cleanup.add(rpcClientCHash::destroy);
+ HelloService cHash = rpcClientCHash.config()
+ .serviceProxy(HelloService.class);
+
+ Assert.assertEquals("g1: load", cHash.echo("load"));
+ Assert.assertEquals(16.8, cHash.sum(10, 6.8), 0.00000001d);
+ Assert.assertEquals(16.8, s3g1.result() + s4g1.result() + s5g1.result(),
+ 0.00000001d);
+
+ Assert.assertEquals("g1: load", cHash.echo("load"));
+ Assert.assertEquals(16.8, cHash.sum(10, 6.8), 0.00000001d);
+ Assert.assertEquals(16.8, s3g1.result() + s4g1.result() + s5g1.result(),
+ 0.00000001d);
+
+ Assert.assertEquals("g1: load", cHash.echo("load"));
+ Assert.assertEquals(16.8, cHash.sum(10, 6.8), 0.00000001d);
+ Assert.assertEquals(16.8, s3g1.result() + s4g1.result() + s5g1.result(),
+ 0.00000001d);
+
+ s3g1.resetResult();
+ s4g1.resetResult();
+ s5g1.resetResult();
+
+ // Test LB "roundRobin"
+ String lbKey = org.apache.hugegraph.config.RpcOptions.RPC_CLIENT_LOAD_BALANCER.name();
+ clientLB.setProperty(lbKey, "roundRobin");
+ RpcClientProvider rpcClientRound = new RpcClientProvider(clientLB);
+ cleanup.add(rpcClientRound::destroy);
+ HelloService round = rpcClientRound.config()
+ .serviceProxy(HelloService.class);
+
+ Assert.assertEquals("g1: load", round.echo("load"));
+ Assert.assertEquals(1.1, round.sum(1, 0.1), 0.00000001d);
+ Assert.assertEquals(1.1, s3g1.result() + s4g1.result() + s5g1.result(),
+ 0.00000001d);
+
+ Assert.assertEquals("g1: load", round.echo("load"));
+ Assert.assertEquals(1.1, round.sum(1, 0.1), 0.00000001d);
+ Assert.assertEquals(2.2, s3g1.result() + s4g1.result() + s5g1.result(),
+ 0.00000001d);
+
+ Assert.assertEquals("g1: load", round.echo("load"));
+ Assert.assertEquals(1.1, round.sum(1, 0.1), 0.00000001d);
+ Assert.assertEquals(3.3, s3g1.result() + s4g1.result() + s5g1.result(),
+ 0.00000001d);
+
+ s3g1.resetResult();
+ s4g1.resetResult();
+ s5g1.resetResult();
+
+ // Test LB "random"
+ clientLB.setProperty(lbKey, "random");
+ RpcClientProvider rpcClientRandom = new RpcClientProvider(clientLB);
+ cleanup.add(rpcClientRandom::destroy);
+ HelloService random = rpcClientRandom.config()
+ .serviceProxy(HelloService.class);
+
+ // Random routing can legitimately leave some providers unvisited.
+ GraphHelloServiceImpl[] services = {s3g1, s4g1, s5g1};
+ int visited = 0;
+ for (int i = 0; i < 12; i++) {
+ if (i < 3) {
+ Assert.assertEquals("g1: load", random.echo("load"));
+ }
+ Assert.assertEquals(1.1, random.sum(1, 0.1), 0.00000001d);
+ int currentVisited = 0;
+ for (GraphHelloServiceImpl service : services) {
+ double result = service.result();
+ Assert.assertTrue(result == 0.0 || result == 1.1);
+ if (result != 0.0) {
+ currentVisited++;
+ }
+ }
+ Assert.assertTrue(currentVisited > 0);
+ Assert.assertTrue(currentVisited >= visited && currentVisited <= visited + 1);
+ visited = currentVisited;
+ }
+
+ s3g1.resetResult();
+ s4g1.resetResult();
+ s5g1.resetResult();
+ } catch (RuntimeException | Error e) {
+ failure = e;
+ throw e;
+ } finally {
+ RuntimeException cleanupFailure = null;
+ for (int i = cleanup.size() - 1; i >= 0; i--) {
+ try {
+ cleanup.get(i).run();
+ } catch (RuntimeException e) {
+ if (failure != null) {
+ failure.addSuppressed(e);
+ } else if (cleanupFailure == null) {
+ cleanupFailure = e;
+ } else {
+ cleanupFailure.addSuppressed(e);
+ }
+ }
+ }
+ if (cleanupFailure != null) {
+ throw cleanupFailure;
+ }
}
- Assert.assertEquals(3.3, s3g1.result() + s4g1.result() + s5g1.result(),
- 0.00000001d);
-
- s3g1.resetResult();
- s4g1.resetResult();
- s5g1.resetResult();
-
- // Destroy all
- rpcClientCHash.destroy();
- rpcClientRound.destroy();
- rpcClientRandom.destroy();
-
- stopServer(rpcServer3);
- stopServer(rpcServer4);
- stopServer(rpcServer5);
}
@Test
@@ -558,7 +630,7 @@ public void testServiceProxy() {
@Test
public void testAddServiceMultiTimesOfSameService() {
- RpcServer rpcServerExport = new RpcServer(config(true));
+ RpcServer rpcServerExport = new RpcServer(config("server-random"));
rpcServerExport.config().addService(HelloService.class,
new HelloServiceImpl());
@@ -578,32 +650,70 @@ public void testAddServiceMultiTimesOfSameService() {
@Test
public void testExportMultiTimesOfSameServer() {
- RpcServer rpcServerExport = new RpcServer(config(true));
- rpcServerExport.config().addService(HelloService.class,
- new HelloServiceImpl());
- rpcServerExport.exportAll();
- rpcServerExport.exportAll();
-
- stopServer(rpcServerExport);
+ RpcServer rpcServerExport = new RpcServer(config("server-random"));
+ try {
+ rpcServerExport.config().addService(HelloService.class, new HelloServiceImpl());
+ rpcServerExport.exportAll();
+ rpcServerExport.exportAll();
+ Assert.assertEquals(1, rpcServerExport.config().configs().get(
+ HelloService.class.getName()).getServer().size());
+ } finally {
+ stopServer(rpcServerExport);
+ }
}
@Test
public void testExportMultiTimesOfSameService() {
- RpcServer rpcServerExport = new RpcServer(config(true));
- rpcServerExport.config().addService(HelloService.class,
- new HelloServiceImpl());
- rpcServerExport.exportAll();
-
- rpcServerExport.config().addService("graph", HelloService.class,
- new HelloServiceImpl());
- rpcServerExport.exportAll();
+ RpcServer rpcServerExport = new RpcServer(config("server-random"));
+ try {
+ rpcServerExport.config().addService(HelloService.class, new HelloServiceImpl());
+ rpcServerExport.exportAll();
+ rpcServerExport.config().addService("graph", HelloService.class, new HelloServiceImpl());
+ rpcServerExport.exportAll();
+ rpcServerExport.exportAll();
+ for (ProviderConfig> provider : rpcServerExport.config().configs().values()) {
+ Assert.assertEquals(1, provider.getServer().size());
+ }
+ } finally {
+ stopServer(rpcServerExport);
+ }
+ }
- stopServer(rpcServerExport);
+ @Test
+ public void testExportRetriesWithoutDuplicatingConfiguredServers() {
+ RpcServer server = new RpcServer(config("server-random"));
+ AtomicInteger exports = new AtomicInteger();
+ IllegalStateException failure = new IllegalStateException("export failed before binding");
+ ProviderConfig provider = new ProviderConfig<>() {
+ @Override
+ public void export() {
+ if (exports.incrementAndGet() == 1) {
+ throw failure;
+ }
+ }
+ };
+ // Sofa equality ignores settings such as serialization; preserve distinct config objects.
+ ServerConfig existing = new ServerConfig().setProtocol("bolt").setHost("127.0.0.1")
+ .setPort(0).setSerialization("java");
+ provider.setServer(existing);
+ server.config().configs().put(HelloService.class.getName(), provider);
+ try {
+ Assert.assertThrows(IllegalStateException.class, server::exportAll,
+ caught -> Assert.assertSame(failure, caught));
+ server.exportAll();
+ server.exportAll();
+ Assert.assertEquals(3, exports.get());
+ Assert.assertEquals(2, provider.getServer().size());
+ Assert.assertSame(existing, provider.getServer().get(0));
+ Assert.assertSame(Whitebox.getInternalState(server, "serverConfig"), provider.getServer().get(1));
+ } finally {
+ stopServer(server);
+ }
}
@Test
public void testExportNoneService() {
- RpcServer rpcServerNoneService = new RpcServer(config(true));
+ RpcServer rpcServerNoneService = new RpcServer(config("server-random"));
// Will be ignored if none service added
rpcServerNoneService.exportAll();
@@ -611,57 +721,81 @@ public void testExportNoneService() {
stopServer(rpcServerNoneService);
}
+ private static RpcClientProvider clientFor(RpcServer server) {
+ HugeConfig config = config(false);
+ String remoteUrlKey = org.apache.hugegraph.config.RpcOptions.RPC_REMOTE_URL.name();
+ config.setProperty(remoteUrlKey, server.host() + ":" + server.port());
+ return new RpcClientProvider(config);
+ }
+
@Test
- public void testUnexportService() {
- RpcServer rpcServerUnexport = new RpcServer(config(true));
+ public void testTemporaryServerDoesNotShareMainListener() {
+ rpcServer.config().addService(HelloService.class, new HelloServiceImpl());
+ startServer(rpcServer);
+ RpcServer temporary = new RpcServer(config("server-random"));
+ try {
+ temporary.config().addService(HelloService.class, new HelloServiceImpl());
+ startServer(temporary);
+ Assert.assertNotEquals(rpcServer.port(), temporary.port());
+ } finally {
+ stopServer(temporary);
+ }
+ HelloService client = rpcClient.config().serviceProxy(HelloService.class);
+ Assert.assertEquals("hello tom!", client.hello("tom"));
+ }
+ @Test
+ public void testUnexportService() {
+ RpcServer rpcServerUnexport = new RpcServer(config("server-random"));
RpcProviderConfig serverConfig = rpcServerUnexport.config();
String service = serverConfig.addService(HelloService.class,
new HelloServiceImpl());
+ String graphService = serverConfig.addService("graph", HelloService.class,
+ new GraphHelloServiceImpl("graph"));
rpcServerUnexport.exportAll();
-
- RpcConsumerConfig clientConfig = rpcClient.config();
- HelloService client = clientConfig.serviceProxy(HelloService.class);
-
- Assert.assertEquals("hello tom!", client.hello("tom"));
-
- rpcServerUnexport.unexport(service);
-
- Assert.assertThrows(SofaRpcException.class, () -> {
- client.hello("tom");
- });
-
- stopServer(rpcServerUnexport);
+ RpcClientProvider isolatedClient = clientFor(rpcServerUnexport);
+ try {
+ HelloService client = isolatedClient.config().serviceProxy(HelloService.class);
+ HelloService graphClient = isolatedClient.config().serviceProxy("graph", HelloService.class);
+ ChannelFuture binding = listener(rpcServerUnexport);
+ Assert.assertEquals("hello tom!", client.hello("tom"));
+ rpcServerUnexport.unexport(service);
+ Assert.assertThrows(SofaRpcException.class, () -> client.hello("tom"));
+ Assert.assertEquals("graph: hello tom!", graphClient.hello("tom"));
+ Assert.assertTrue(binding.channel().isOpen());
+ rpcServerUnexport.unexport(graphService);
+ Assert.assertTrue(binding.channel().closeFuture().awaitUninterruptibly(5, TimeUnit.SECONDS));
+ Assert.assertTrue(binding.channel().closeFuture().isSuccess());
+ Assert.assertTrue(serverConfig.configs().isEmpty());
+ Assert.assertThrows(SofaRpcException.class, () -> graphClient.hello("tom"));
+ } finally {
+ isolatedClient.destroy();
+ stopServer(rpcServerUnexport);
+ }
}
@Test
public void testUnexportAllService() {
- RpcServer rpcServerUnexport = new RpcServer(config(true));
-
+ RpcServer rpcServerUnexport = new RpcServer(config("server-random"));
RpcProviderConfig serverConfig = rpcServerUnexport.config();
serverConfig.addService(HelloService.class, new HelloServiceImpl());
serverConfig.addService("graph", HelloService.class,
new GraphHelloServiceImpl("graph"));
rpcServerUnexport.exportAll();
-
- RpcConsumerConfig clientConfig = rpcClient.config();
- HelloService client = clientConfig.serviceProxy(HelloService.class);
- HelloService clientG = clientConfig.serviceProxy("graph",
- HelloService.class);
-
- Assert.assertEquals("hello tom!", client.hello("tom"));
- Assert.assertEquals("graph: hello tom!", clientG.hello("tom"));
-
- rpcServerUnexport.unexportAll();
-
- Assert.assertThrows(SofaRpcException.class, () -> {
- client.hello("tom");
- });
- Assert.assertThrows(SofaRpcException.class, () -> {
- clientG.hello("tom");
- });
-
- stopServer(rpcServerUnexport);
+ RpcClientProvider isolatedClient = clientFor(rpcServerUnexport);
+ try {
+ RpcConsumerConfig clientConfig = isolatedClient.config();
+ HelloService client = clientConfig.serviceProxy(HelloService.class);
+ HelloService clientG = clientConfig.serviceProxy("graph", HelloService.class);
+ Assert.assertEquals("hello tom!", client.hello("tom"));
+ Assert.assertEquals("graph: hello tom!", clientG.hello("tom"));
+ rpcServerUnexport.unexportAll();
+ Assert.assertThrows(SofaRpcException.class, () -> client.hello("tom"));
+ Assert.assertThrows(SofaRpcException.class, () -> clientG.hello("tom"));
+ } finally {
+ isolatedClient.destroy();
+ stopServer(rpcServerUnexport);
+ }
}
@Test
@@ -674,6 +808,108 @@ public void testUnexportNotExistService() {
});
}
+ @Test
+ public void testRemoveServiceUsesImplementationIdentity() throws Exception {
+ RpcServer server = new RpcServer(config("server-random"));
+ GraphHelloServiceImpl original = new GraphHelloServiceImpl("graph");
+ RpcProviderConfig providers = server.config();
+ String serviceId = providers.addService("graph", HelloService.class, original);
+ PausedProvider provider = new PausedProvider();
+ provider.setId(serviceId).setUniqueId("graph");
+ provider.setInterfaceId(HelloService.class.getName()).setRef(original);
+ providers.configs().put(serviceId, provider);
+ server.exportAll();
+ RpcClientProvider client = clientFor(server);
+ ExecutorService worker = Executors.newFixedThreadPool(2);
+ try {
+ HelloService proxy = client.config().serviceProxy("graph", HelloService.class);
+ Assert.assertFalse(providers.removeService(serviceId, new GraphHelloServiceImpl("graph")));
+ Assert.assertEquals("graph: hello tom!", proxy.hello("tom"));
+ provider.armed = true;
+ Future> exporting = worker.submit(server::exportAll);
+ Assert.assertTrue(provider.entered.await(5, TimeUnit.SECONDS));
+ CountDownLatch removing = new CountDownLatch(1);
+ Future removal = worker.submit(() -> {
+ removing.countDown();
+ return providers.removeService(serviceId, original);
+ });
+ Assert.assertTrue(removing.await(5, TimeUnit.SECONDS));
+ Assert.assertThrows(TimeoutException.class, () -> removal.get(1, TimeUnit.SECONDS));
+ Assert.assertSame(provider, providers.configs().get(serviceId));
+ provider.release.countDown();
+ exporting.get(5, TimeUnit.SECONDS);
+ Assert.assertTrue(removal.get(5, TimeUnit.SECONDS));
+ Assert.assertFalse(providers.configs().containsKey(serviceId));
+ GraphHelloServiceImpl replacement = new GraphHelloServiceImpl("replacement");
+ providers.addService("graph", HelloService.class, replacement);
+ server.exportAll();
+ Assert.assertFalse(providers.removeService(serviceId, original));
+ Assert.assertSame(replacement, providers.configs().get(serviceId).getRef());
+ Assert.assertEquals("replacement: hello tom!", proxy.hello("tom"));
+ } finally {
+ provider.release.countDown();
+ worker.shutdownNow();
+ try {
+ Assert.assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
+ } finally {
+ client.destroy();
+ stopServer(server);
+ }
+ }
+ }
+
+ private static class PausedProvider extends ProviderConfig {
+
+ private final CountDownLatch entered = new CountDownLatch(1);
+ private final CountDownLatch release = new CountDownLatch(1);
+ private volatile boolean armed;
+
+ @Override
+ public ProviderConfig setTimeout(int timeout) {
+ if (this.armed) {
+ this.entered.countDown();
+ try {
+ E.checkState(this.release.await(5, TimeUnit.SECONDS), "RPC export barrier timed out");
+ } catch (InterruptedException failure) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(failure);
+ }
+ this.armed = false;
+ }
+ return super.setTimeout(timeout);
+ }
+ }
+
+ @Test
+ public void testRemoveServiceProxyUsesProxyIdentity() {
+ RpcServer server = new RpcServer(config("server-random"));
+ server.config().addService("graph", HelloService.class, new GraphHelloServiceImpl("graph"));
+ server.config().addService("other", HelloService.class, new GraphHelloServiceImpl("other"));
+ server.exportAll();
+ RpcClientProvider client = clientFor(server);
+ try {
+ RpcConsumerConfig consumers = client.config();
+ HelloService original = consumers.serviceProxy("graph", HelloService.class);
+ HelloService second = consumers.serviceProxy("graph", HelloService.class);
+ HelloService other = consumers.serviceProxy("other", HelloService.class);
+ Assert.assertNotSame(original, second);
+ Assert.assertTrue(consumers.removeServiceProxy("graph", HelloService.class.getName(), original));
+ Assert.assertThrows(SofaRpcRuntimeException.class, () -> original.hello("tom"));
+ Assert.assertEquals("graph: hello tom!", second.hello("tom"));
+ Assert.assertFalse(consumers.removeServiceProxy("graph", HelloService.class.getName(), original));
+ Assert.assertTrue(consumers.removeServiceProxy("graph", HelloService.class.getName(), second));
+ List> bootstraps = Whitebox.getInternalState(consumers, "bootstraps");
+ Assert.assertEquals(1, bootstraps.size());
+ HelloService replacement = consumers.serviceProxy("graph", HelloService.class);
+ Assert.assertFalse(consumers.removeServiceProxy("graph", HelloService.class.getName(), original));
+ Assert.assertEquals("graph: hello tom!", replacement.hello("tom"));
+ Assert.assertEquals("other: hello tom!", other.hello("tom"));
+ } finally {
+ client.destroy();
+ stopServer(server);
+ }
+ }
+
@Test
public void testServerDisabled() {
HugeConfig clientConf = config(false);
diff --git a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
index a19b5522c8..c006ab13bf 100644
--- a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
+++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java
@@ -24,7 +24,8 @@
@Suite.SuiteClasses({
VersionTest.class,
ExceptionTest.class,
- ServerClientTest.class
+ ServerClientTest.class,
+ RpcConfigTest.class
})
public class UnitTestSuite {
}
diff --git a/hugegraph-commons/pom.xml b/hugegraph-commons/pom.xml
index b9e780bd32..a3ef4983df 100644
--- a/hugegraph-commons/pom.xml
+++ b/hugegraph-commons/pom.xml
@@ -89,19 +89,15 @@
-
-
- 1.7.0UTF-8${project.basedir}/..
- 1.8
- 1.8
+ 112.18.01.10
- 2.8.0
+ 2.10.11.9.43.2.2
- 3.12.0
+ 3.18.02.71.1330.0-jre
@@ -110,7 +106,6 @@
3.28.0-GA3.0.33.0.3
- 2.14.0-rc14.13.14.1.04.0.0-RC2
@@ -132,8 +127,6 @@
maven-compiler-plugin
- ${compiler.source}
- ${compiler.target}500
@@ -193,7 +186,6 @@
org.jacocojacoco-maven-plugin
- 0.8.7pre-unit-test
@@ -288,9 +280,12 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20${skipCommonsTests}
+
+ @{argLine}
+ "@${project.parent.basedir}/../hugegraph-server/hugegraph-test/conf/jvm-test-module.options"
+
diff --git a/hugegraph-pd/Dockerfile b/hugegraph-pd/Dockerfile
index 655860d407..c7050826ac 100644
--- a/hugegraph-pd/Dockerfile
+++ b/hugegraph-pd/Dockerfile
@@ -18,7 +18,7 @@
# Dockerfile for HugeGraph PD
# 1st stage: build source code
-FROM --platform=$BUILDPLATFORM maven:3.9.0-eclipse-temurin-11 AS build
+FROM --platform=$BUILDPLATFORM maven:3.9.16-eclipse-temurin-17 AS build
WORKDIR /pkg
@@ -35,7 +35,7 @@ RUN --mount=type=cache,id=hugegraph-maven-${SOURCE_REVISION},target=/root/.m2,sh
# 2nd stage: runtime env
# Note: ZGC (The Z Garbage Collector) is only supported on ARM-Mac with java > 13
-FROM eclipse-temurin:11-jre-jammy
+FROM eclipse-temurin:17-jre-jammy
LABEL maintainer="HugeGraph Docker Maintainers "
diff --git a/hugegraph-pd/README.md b/hugegraph-pd/README.md
index 5050025565..a5dc5d6f13 100644
--- a/hugegraph-pd/README.md
+++ b/hugegraph-pd/README.md
@@ -3,6 +3,8 @@
[](https://www.apache.org/licenses/LICENSE-2.0.html)
[](https://github.com/apache/hugegraph)
+
+
## Overview
HugeGraph PD (Placement Driver) is a meta server that provides cluster management and coordination services for HugeGraph distributed deployments. It serves as the central control plane responsible for:
@@ -36,8 +38,8 @@ For detailed architecture and design, see [Architecture Documentation](docs/arch
### Prerequisites
-- **Java**: 11 or higher
-- **Maven**: 3.5 or higher
+- **Java**: 17 (currently the only supported release)
+- **Maven**: 3.6.3 or higher
- **Disk Space**: At least 1GB for PD data directory
### Build
@@ -81,11 +83,18 @@ bin/stop-hugegraph-pd.sh
bin/start-hugegraph-pd.sh [-g GC_TYPE] [-j "JVM_OPTIONS"] [-y ENABLE_OTEL] [-d DAEMON]
```
-- `-g`: GC type (`g1` or `ZGC`, default: `g1`)
+- `-g`: Explicit GC selection (`g1`/`G1` or `zgc`/`ZGC`). Without `-g`, JVM defaults or the collector
+ set in `JAVA_OPTIONS` apply. Explicit `-g g1` selects G1 even when JVM ergonomics would choose Serial GC;
+ unknown values are rejected. When using explicit `-g`, do not select another collector in `JAVA_OPTIONS`
+ or `-j`; conflicting selections are rejected by the JVM.
- `-j`: Custom JVM options (e.g., `-j "-Xmx4g -Xms4g"`)
- `-y`: Enable OpenTelemetry tracing (`true` or `false`, default: `false`)
- `-d`: Daemon mode (`true` = daemon, `false` = foreground; default: `true`). Set to `false` when running under Docker or a process supervisor so the container exits if Java dies.
+The launcher always loads `bin/jvm-module.options`, including with custom `JAVA_OPTIONS` or `-j`.
+Keep this file in the distribution: it grants the Java 17 access required by Hessian Raft serialization;
+startup fails if the file is missing or unreadable. Direct Java launches must include `@bin/jvm-module.options`.
+
### Configuration
Key configuration file: `conf/application.yml`
@@ -299,7 +308,7 @@ docker/docker-compose-3pd-3store-3server.yml
PD exposes metrics via REST API at:
- Health check: `http://:8620/actuator/health`
-- Liveness: `http://:8620/v1/health` (REST listener is up)
+- Liveness: `http://:8620/v1/health` (`503` for terminal Raft error or shutdown; elections and temporary quorum loss remain healthy)
- Readiness: `http://:8620/v1/ready` (`200` only while the PD sees a raft leader)
- Metrics: `http://:8620/actuator/metrics`
diff --git a/hugegraph-pd/docs/api-reference.md b/hugegraph-pd/docs/api-reference.md
index 8fbd28a651..35d70fadd4 100644
--- a/hugegraph-pd/docs/api-reference.md
+++ b/hugegraph-pd/docs/api-reference.md
@@ -809,9 +809,13 @@ Two unauthenticated endpoints are meant for probes and startup gates:
| Endpoint | Meaning | Status |
|----------|---------|--------|
-| `GET /v1/health` | Liveness: the REST listener is up. Does not consult raft. | always `200` |
+| `GET /v1/health` | Liveness: the REST listener is up and Raft has no terminal error. Empty text body. | `503` for `STATE_ERROR` or `STATE_SHUTDOWN`, `200` otherwise |
| `GET /v1/ready` | Readiness: the raft node is active and sees a leader, so this PD is inside a quorum. | `200` when ready, `503` otherwise |
+The health probe reads Raft state only; it does not verify metadata-store initialization.
+Metadata open failures propagate through startup; bounded LOCK retry remains tracked in
+[#3226](https://github.com/apache/hugegraph/issues/3226).
+
```bash
curl -i http://localhost:8620/v1/ready
```
@@ -829,6 +833,11 @@ A follower reports `"state": "STATE_FOLLOWER"` with `"isLeader": false`. When th
The answer is served from state the raft callbacks maintain rather than from the raft node, so it stays prompt while an election is running and never waits on the node lock. `state` is therefore the last change raft announced. A PD reports `STATE_UNINITIALIZED` with `"ready": false` from process start until its first raft callback, which is the ordinary startup window before a quorum first forms, and jraft emits no callback for candidacy or leadership transfer, so a candidate reports `STATE_FOLLOWER` with `"ready": false`.
+After a fatal snapshot error, `/v1/ready` retains `STATE_ERROR` even after the leader
+steps down, and `/v1/health` returns `503`. Fix the underlying disk or storage
+problem and restart the affected PD: freeing disk space alone does not restart
+JRaft's terminal state machine.
+
Point Kubernetes readiness probes, `depends_on` healthchecks and any "wait for PD" script at `/v1/ready`; keep liveness probes on `/v1/health` so a PD that merely lost its leader is not restarted.
Match on the body rather than on the status code alone. A PD that predates this endpoint does not reliably answer `404` for it: `RestAuthentication` refuses a request it does not exclude by writing an error envelope, and as of 1.7.0 it does so without setting a status, so an unknown path answers `200` with `{"status":-1,"error":"Unauthorized!"}`. A status-only probe therefore reads such a PD as ready. The body match holds whichever status a refusal carries: a shell gate should use `curl -fsS http://:8620/v1/ready | grep -q '"ready":true'`, and a Kubernetes `httpGet` probe should be paired with a PD image that carries the endpoint.
@@ -871,6 +880,19 @@ A cluster has lost its quorum when `sum(hg_raft_leader) == 0` or when `hg_raft_h
Do not aggregate `hg_raft_alive_peers` across instances: it is `NaN` on every node but the leader, and one `NaN` sample turns the result of `sum()` or `avg()` into `NaN` as well. Select the leader's series instead, for example `hg_raft_alive_peers and on(instance) (hg_raft_leader == 1)`.
+#### Store state gauges
+
+Exported on `/actuator/prometheus` for each store registered in PD, from the store and shard group metadata PD keeps:
+
+| Gauge | Value |
+|-------|-------|
+| `hg_store_state{store="",state=""}` | `1` for the store's current state, `0` for the others. `state` is one of `Unknown`, `Pending`, `Up`, `Offline`, `Exiting`, `Tombstone` |
+| `hg_store_leader_count{store=""}` | Number of shard groups whose leader is on the store |
+
+A new store's series can take one extra scrape to appear, and they disappear once the store is removed from PD. Every PD member reads the same replicated metadata, so select one instance, such as the leader, before aggregating: `hg_store_leader_count and on(instance) (hg_raft_leader == 1)`.
+
+`hg_store_leader_count` shows where leadership sits during and after a Store recovery: a rebuilt store reports `0` until leaders move back to it, either through `/v1/task/balanceLeaders` or by raft elections.
+
### Partition API
#### List Partitions
diff --git a/hugegraph-pd/docs/architecture.md b/hugegraph-pd/docs/architecture.md
index 080189be95..6fd56dd212 100644
--- a/hugegraph-pd/docs/architecture.md
+++ b/hugegraph-pd/docs/architecture.md
@@ -54,7 +54,7 @@ HugeGraph PD (Placement Driver) is the control plane for HugeGraph distributed d
- **Storage**: RocksDB for persistent metadata
- **Communication**: gRPC with Protocol Buffers
- **Framework**: Spring Boot for REST APIs and dependency injection
-- **Language**: Java 11+
+- **Language**: Java 17 (currently supported release)
## Module Architecture
diff --git a/hugegraph-pd/docs/configuration.md b/hugegraph-pd/docs/configuration.md
index 4e60d8e8be..c2d6eecc9f 100644
--- a/hugegraph-pd/docs/configuration.md
+++ b/hugegraph-pd/docs/configuration.md
@@ -119,6 +119,9 @@ raft:
|-----------|------|---------|-------------|
| `raft.address` | String | `127.0.0.1:8610` | Raft service address for this PD node. Format: `:`. Must be unique across all PD nodes. |
| `raft.peers-list` | String | `127.0.0.1:8610` | Comma-separated list of all PD nodes' Raft addresses. Used for cluster formation and leader election. |
+| `raft.rpc-timeout` | Integer | `10000` | Timeout of a Raft RPC between PD nodes, in milliseconds. It is also the deadline of the ReadIndex wait PD runs before it reads an id counter: `getId` and `getCId` fail when the leader cannot confirm and apply a ReadIndex in this time, for example while a new leader is still committing its first entry. |
+| `raft.rpc-connect-timeout` | Integer | `1000` | Timeout for opening a connection to a peer, in milliseconds. A candidate opens its connections one peer at a time while it holds the Raft node lock, so an election waits this long for each peer that accepts the connection but does not answer (a stopped process, a lost host). It is the timeout of the Ping that opens a connection; requests on a connection that is already open use `raft.rpc-timeout`. |
+| `raft.rpc-install-snapshot-timeout` | Integer | `0` | Timeout for sending a snapshot to a follower that fell behind the log, in milliseconds. It bounds the transfer of the whole metadata store. `0` uses `raft.rpc-timeout`, the value this timeout had before it became a separate option; raise it if large snapshots time out. |
**Critical Rules**:
1. `raft.address` must be unique for each PD node
diff --git a/hugegraph-pd/docs/development.md b/hugegraph-pd/docs/development.md
index 514bd989a1..61ed03f3b5 100644
--- a/hugegraph-pd/docs/development.md
+++ b/hugegraph-pd/docs/development.md
@@ -18,10 +18,10 @@ This document provides comprehensive guidance for developing, testing, and contr
Ensure you have the following tools installed:
-| Tool | Minimum Version | Recommended | Purpose |
+| Tool | Version Requirement | Recommended | Purpose |
|------|----------------|-------------|---------|
-| **JDK** | 11 | 11 or 17 | Java runtime and compilation |
-| **Maven** | 3.5.0 | 3.8+ | Build tool and dependency management |
+| **JDK** | 17 (only supported release) | 17 LTS | Java runtime and compilation |
+| **Maven** | 3.6.3+ | 3.8+ | Build tool and dependency management |
| **Git** | 2.0+ | Latest | Version control |
| **IDE** | N/A | IntelliJ IDEA | Development environment |
@@ -30,11 +30,11 @@ Ensure you have the following tools installed:
```bash
# Check Java version
java -version
-# Expected: openjdk version "11.0.x" or later
+# Expected: openjdk version "17.0.x"
# Check Maven version
mvn -version
-# Expected: Apache Maven 3.5.0 or later
+# Expected: Apache Maven 3.6.3 or later
# Check Git version
git --version
@@ -77,8 +77,8 @@ Required for Lombok support:
#### Configure JDK
1. **File → Project Structure → Project**
-2. **Project SDK**: Select JDK 11 or 17
-3. **Project language level**: 11
+2. **Project SDK**: Select JDK 17
+3. **Project language level**: 17
4. **Apply** and **OK**
## Building from Source
@@ -196,10 +196,10 @@ mvn test -pl hugegraph-pd/hg-pd-test -am -P pd-rest-test
```bash
# Run specific test class
-mvn -pl hugegraph-pd/hg-pd-test test -Dtest=PartitionServiceTest -DfailIfNoTests=false
+mvn -pl hugegraph-pd/hg-pd-test test -Dtest=PartitionServiceTest
# Run specific test method
-mvn -pl hugegraph-pd/hg-pd-test test -Dtest=PartitionServiceTest#testSplitPartition -DfailIfNoTests=false
+mvn -pl hugegraph-pd/hg-pd-test test -Dtest=PartitionServiceTest#testSplitPartition
```
#### Test from IDE
@@ -382,7 +382,7 @@ if (store == null) {
- **Main class**: `org.apache.hugegraph.pd.HgPdApplication` (in `hg-pd-service`)
- **Program arguments**: `--spring.config.location=file:./conf/application.yml`
- **Working directory**: `hugegraph-pd/hg-pd-dist/target/hugegraph-pd-/`
- - **JRE**: 11 or 17
+ - **JRE**: 17
2. Set breakpoints in code
diff --git a/hugegraph-pd/hg-pd-cli/pom.xml b/hugegraph-pd/hg-pd-cli/pom.xml
index 01524c7479..ec24ac969d 100644
--- a/hugegraph-pd/hg-pd-cli/pom.xml
+++ b/hugegraph-pd/hg-pd-cli/pom.xml
@@ -31,7 +31,6 @@
hg-pd-cli
- 2.15.2
@@ -49,7 +48,7 @@
com.alipay.sofajraft-core
- ${pd-store-jraft.version}
+ ${jraft.version}org.rocksdb
@@ -78,38 +77,32 @@
org.yamlsnakeyaml
- 1.33
+ ${snakeyaml.version}testcom.fasterxml.jackson.corejackson-annotations
- ${jackson.version}com.fasterxml.jackson.corejackson-core
- ${jackson.version}com.fasterxml.jackson.corejackson-databind
- ${jackson.version}com.fasterxml.jackson.modulejackson-module-jaxb-annotations
- ${jackson.version}com.fasterxml.jackson.jaxrsjackson-jaxrs-base
- ${jackson.version}com.fasterxml.jackson.jaxrsjackson-jaxrs-json-provider
- ${jackson.version}org.apache.hugegraph
diff --git a/hugegraph-pd/hg-pd-client/pom.xml b/hugegraph-pd/hg-pd-client/pom.xml
index c2413d3564..569ee2284c 100644
--- a/hugegraph-pd/hg-pd-client/pom.xml
+++ b/hugegraph-pd/hg-pd-client/pom.xml
@@ -69,7 +69,7 @@
org.yamlsnakeyaml
- 1.28
+ ${snakeyaml.version}test
diff --git a/hugegraph-pd/hg-pd-core/pom.xml b/hugegraph-pd/hg-pd-core/pom.xml
index 38837f1e7d..e020578f0d 100644
--- a/hugegraph-pd/hg-pd-core/pom.xml
+++ b/hugegraph-pd/hg-pd-core/pom.xml
@@ -38,7 +38,7 @@
com.alipay.sofajraft-core
- ${pd-store-jraft.version}
+ ${jraft.version}org.rocksdb
@@ -49,7 +49,7 @@
org.rocksdbrocksdbjni
- 6.29.5
+ ${rocksdb.version}org.apache.hugegraph
@@ -58,7 +58,7 @@
org.springframeworkspring-context
- 5.3.20
+ ${spring-framework.version}org.apache.hugegraph
@@ -67,7 +67,7 @@
org.springframework.bootspring-boot
- 2.5.14
+ ${spring-boot.version}org.projectlombok
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/StoreNodeService.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/StoreNodeService.java
index 3503d1ffc8..f8d8b81ed6 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/StoreNodeService.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/StoreNodeService.java
@@ -158,6 +158,15 @@ public Metapb.Store register(Metapb.Store store) throws PDException {
}
// offline or up, or in the initial activation list, go live automatically
+ // TODO: do not mark a re-registering Store Up before it has restored its partition engines
+ // (HgStoreEngine.restoreLocalPartitionEngine); report a restoring state, or expose
+ // restore-complete per shard group, so a rolling restart can wait on it. The Helm chart
+ // (helm/hugegraph) production preset (values-cluster.yaml) keeps Store rollouts on
+ // OnDelete with a manual /v1/shardGroups barrier between Pod deletions; the default
+ // values use RollingUpdate. Restore-complete alone does not retire the barrier: a stopped
+ // Store stays Up until its keep-alive entry expires, so keep the rollout checks until PD
+ // also exposes liveness that tells stale membership apart.
+ // https://github.com/apache/hugegraph/issues/3229
Metapb.StoreState storeState = lastStore.getState();
if (storeState == Metapb.StoreState.Offline || storeState == Metapb.StoreState.Up
|| inInitialStoreList(store)) {
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/TaskScheduleService.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/TaskScheduleService.java
index 003a4278dc..c111a6f85c 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/TaskScheduleService.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/TaskScheduleService.java
@@ -190,6 +190,22 @@ public void onStoreRaftChanged(Metapb.Store store) {
public void shutDown() {
executor.shutdownNow();
+ boolean interrupted = Thread.interrupted();
+ try {
+ while (!executor.isTerminated()) {
+ try {
+ if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
+ log.warn("Waiting for PD scheduled tasks to stop");
+ }
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
}
private boolean isLeader() {
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java
index 56bd58b34d..3049915602 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/config/PDConfig.java
@@ -186,11 +186,24 @@ public class Raft {
private int snapshotInterval;
@Value("${raft.rpc-timeout:10000}")
private int rpcTimeout;
+ // Bounds the ping that opens a connection to a peer; kept apart from rpc-timeout
+ // because a candidate opens its connections while it holds the raft node lock
+ @Value("${raft.rpc-connect-timeout:1000}")
+ private int rpcConnectTimeout = 1000;
+ // A follower that fell behind the log receives the whole store within this time;
+ // 0 keeps the timeout it always had, raft.rpc-timeout
+ @Value("${raft.rpc-install-snapshot-timeout:0}")
+ private int rpcInstallSnapshotTimeout;
@Value("${grpc.host}")
private String host;
@Value("${server.port}")
private int port;
+ public int getRpcInstallSnapshotTimeout() {
+ return this.rpcInstallSnapshotTimeout > 0 ? this.rpcInstallSnapshotTimeout :
+ this.rpcTimeout;
+ }
+
@Value("${pd.cluster_id:1}")
private long clusterId;
@Value("${grpc.port}")
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/IdMetaStore.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/IdMetaStore.java
index 8e1fde67d6..c0f362d598 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/IdMetaStore.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/IdMetaStore.java
@@ -27,6 +27,7 @@
import org.apache.hugegraph.pd.common.PDException;
import org.apache.hugegraph.pd.config.PDConfig;
+import org.apache.hugegraph.pd.grpc.Pdpb;
import org.apache.hugegraph.pd.store.KV;
import com.caucho.hessian.io.Hessian2Input;
@@ -47,6 +48,8 @@ public class IdMetaStore extends MetadataRocksDBStore {
private static final String SEPARATOR = "@";
private static final ConcurrentHashMap SEQUENCES = new ConcurrentHashMap<>();
public static long CID_DEL_TIMEOUT = 24 * 3600 * 1000;
+ // An allocation whose write was rejected for its term starts again this often at most
+ private static final int ALLOCATION_ATTEMPTS = 3;
private final long clusterId;
public IdMetaStore(PDConfig pdConfig) {
@@ -75,17 +78,50 @@ public static byte[] longToBytes(long l) {
* @throws PDException
*/
public long getId(String key, int delta) throws PDException {
+ return inLeaderTerm(term -> allocateId(key, delta, term));
+ }
+
+ private long allocateId(String key, int delta, long term) throws PDException {
Object probableLock = getLock(key);
byte[] keyBs = (ID_PREFIX + key).getBytes(Charset.defaultCharset());
synchronized (probableLock) {
byte[] bs = getOne(keyBs);
long current = bs != null ? bytesToLong(bs) : 0L;
long next = current + delta;
- put(keyBs, longToBytes(next));
+ getStore().put(keyBs, longToBytes(next), term);
return current;
}
}
+ /**
+ * Runs an allocation that reads local state and then writes through raft, as two steps
+ * and not one raft entry. The reads follow a ReadIndex wait: a leader elected a moment
+ * ago may not have applied its predecessor's last write yet, and would hand out the same
+ * id again. The writes carry the leader term of that wait: a request that pauses between
+ * its read and its write could otherwise write its stale result after this node lost the
+ * leadership and won it back, over what the leader in between allocated. jraft rejects
+ * such a write, and the allocation starts again from a fresh read.
+ */
+ private long inLeaderTerm(Allocation allocation) throws PDException {
+ for (int attempt = 1; ; attempt++) {
+ // Fails with NOT_LEADER when this node is not the leader, which ends the loop
+ long term = getStore().waitReadIndexInTerm();
+ try {
+ return allocation.run(term);
+ } catch (PDException e) {
+ if (e.getErrorCode() != Pdpb.ErrorType.NOT_LEADER_VALUE ||
+ attempt >= ALLOCATION_ATTEMPTS) {
+ throw e;
+ }
+ }
+ }
+ }
+
+ private interface Allocation {
+
+ long run(long term) throws PDException;
+ }
+
private Object getLock(String key) {
Object probableLock = new Object();
Object currentLock = SEQUENCES.putIfAbsent(key, probableLock);
@@ -119,6 +155,12 @@ public void resetId(String key) throws PDException {
* @throws PDException
*/
public long getCId(String key, String name, long max) throws PDException {
+ // The delayed-deletion slots are read locally like the counter in getId()
+ return inLeaderTerm(term -> allocateCId(key, name, max, term));
+ }
+
+ private long allocateCId(String key, String name, long max, long term)
+ throws PDException {
// Check for expired cids to delete. The frequency of deleting graphs is relatively low,
// so this has little performance impact.
byte[] delKeyPrefix = new StringBuffer()
@@ -131,8 +173,8 @@ public long getCId(String key, String name, long max) throws PDException {
if (value.length >= 2) {
if (System.currentTimeMillis() - value[1] > CID_DEL_TIMEOUT) {
try {
- delCId(key, value[0]);
- remove(kv.getKey());
+ getStore().remove(genCIDSlotKey(key, value[0]), term);
+ getStore().remove(kv.getKey(), term);
} catch (Exception e) {
log.error("Exception ", e);
}
@@ -145,10 +187,11 @@ public long getCId(String key, String name, long max) throws PDException {
byte[] value = getOne(cidDelayKey);
if (value != null) {
// Remove from delayed deletion queue
- remove(cidDelayKey);
+ getStore().remove(cidDelayKey, term);
return ((long[]) deserialize(value))[0];
} else {
- return getCId(key, max);
+ // In the term of the wait above, which covers the counter and slots too
+ return allocateCId(key, max, term);
}
}
}
@@ -173,6 +216,11 @@ public long delCIdDelay(String key, String name, long cid) throws PDException {
* @throws PDException
*/
public long getCId(String key, long max) throws PDException {
+ // Same two steps as getId(): read the counter and slots, then write through raft
+ return inLeaderTerm(term -> allocateCId(key, max, term));
+ }
+
+ private long allocateCId(String key, long max, long term) throws PDException {
Object probableLock = getLock(key);
byte[] keyBs = (CID_PREFIX + key).getBytes(Charset.defaultCharset());
synchronized (probableLock) {
@@ -203,8 +251,8 @@ public long getCId(String key, long max) throws PDException {
if (current == last) {
return -1;
}
- put(genCIDSlotKey(key, current), longToBytes(current));
- put(keyBs, longToBytes(current + 1));
+ getStore().put(genCIDSlotKey(key, current), longToBytes(current), term);
+ getStore().put(keyBs, longToBytes(current + 1), term);
return current;
}
}
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/MetadataFactory.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/MetadataFactory.java
index 673c313dfc..954a9f9e2e 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/MetadataFactory.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/meta/MetadataFactory.java
@@ -28,30 +28,47 @@
*/
public class MetadataFactory {
- private static HgKVStore store = null;
+ private static HgKVStore store;
+ private static boolean shutdown;
- public static HgKVStore getStore(PDConfig pdConfig) {
+ public static synchronized HgKVStore getStore(PDConfig pdConfig) {
+ if (shutdown) {
+ throw new IllegalStateException("PD metadata store has shut down");
+ }
if (store == null) {
- synchronized (MetadataFactory.class) {
- if (store == null) {
- HgKVStore proto = new HgKVStoreImpl();
- //proto.init(pdConfig);
- store = pdConfig.getRaft().isEnable() ?
- new RaftKVStore(RaftEngine.getInstance(), proto) :
- proto;
- store.init(pdConfig);
+ HgKVStore proto = new HgKVStoreImpl();
+ HgKVStore candidate = pdConfig.getRaft().isEnable() ?
+ new RaftKVStore(RaftEngine.getInstance(), proto) : proto;
+ try {
+ candidate.init(pdConfig);
+ store = candidate;
+ } catch (RuntimeException | Error failure) {
+ try {
+ candidate.close();
+ } catch (RuntimeException | Error cleanup) {
+ if (cleanup != failure) {
+ failure.addSuppressed(cleanup);
+ }
}
+ throw failure;
}
}
return store;
}
- public static void closeStore() {
+ public static synchronized void closeStore() {
if (store != null) {
store.close();
}
}
+ // Final service shutdown must not allow a late facade to create a new DB.
+ public static synchronized void shutdownStore() {
+ shutdown = true;
+ closeStore();
+ store = null;
+ }
+
public static StoreInfoMeta newStoreInfoMeta(PDConfig pdConfig) {
return new StoreInfoMeta(pdConfig);
}
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java
index d39384909d..48fe074d76 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java
@@ -46,6 +46,7 @@
import com.alipay.sofa.jraft.Node;
import com.alipay.sofa.jraft.RaftGroupService;
import com.alipay.sofa.jraft.Status;
+import com.alipay.sofa.jraft.closure.ReadIndexClosure;
import com.alipay.sofa.jraft.conf.Configuration;
import com.alipay.sofa.jraft.core.Replicator;
import com.alipay.sofa.jraft.core.State;
@@ -58,7 +59,6 @@
import com.alipay.sofa.jraft.rpc.RpcServer;
import com.alipay.sofa.jraft.rpc.impl.BoltRpcServer;
import com.alipay.sofa.jraft.util.Endpoint;
-import com.alipay.sofa.jraft.util.internal.ThrowUtil;
import io.netty.channel.ChannelHandler;
import lombok.extern.slf4j.Slf4j;
@@ -74,6 +74,16 @@ public class RaftEngine {
*/
private static final long ALIVE_PEERS_REFRESH_MS = 1000L;
+ private static final long READ_INDEX_RETRY_DELAY_MS = 20L;
+ // An EBUSY retry waits 20 ms doubled per EBUSY answer in a row, up to 20 << 5 = 640 ms
+ private static final int READ_INDEX_BUSY_MAX_SHIFT = 5;
+ private static final ScheduledExecutorService READ_INDEX_RETRY =
+ Executors.newSingleThreadScheduledExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "pd-raft-read-index-retry");
+ thread.setDaemon(true);
+ return thread;
+ });
+
private volatile static RaftEngine instance = new RaftEngine();
private RaftStateMachine stateMachine;
private String groupId = "pd_raft";
@@ -85,7 +95,7 @@ public class RaftEngine {
private volatile int alivePeerCount = -1;
private ScheduledExecutorService alivePeersRefresher;
- public RaftEngine() {
+ private RaftEngine() {
this.stateMachine = new RaftStateMachine();
}
@@ -133,9 +143,7 @@ public synchronized boolean init(PDConfig.Raft config) {
// Snapshot interval
nodeOptions.setSnapshotIntervalSecs(config.getSnapshotInterval());
- nodeOptions.setRpcConnectTimeoutMs(config.getRpcTimeout());
- nodeOptions.setRpcDefaultTimeout(config.getRpcTimeout());
- nodeOptions.setRpcInstallSnapshotTimeout(config.getRpcTimeout());
+ setRpcTimeouts(nodeOptions, config);
// TODO: tune RaftOptions for PD (see hugegraph-store PartitionEngine for reference)
final PeerId serverId = JRaftUtils.getPeerId(config.getAddress());
@@ -145,6 +153,7 @@ public synchronized boolean init(PDConfig.Raft config) {
// construct raft group and start raft
this.raftGroupService =
new RaftGroupService(groupId, serverId, nodeOptions, rpcServer, true);
+ this.stateMachine.resetForNewNode();
this.raftNode = raftGroupService.start(false);
startAlivePeersRefresher();
log.info("RaftEngine start successfully: id = {}, peers list = {}", groupId,
@@ -152,6 +161,20 @@ public synchronized boolean init(PDConfig.Raft config) {
return this.raftNode != null;
}
+ /**
+ * Wire the three raft rpc timeouts from their own options. jraft pings a peer to open a
+ * connection, and a candidate does so for every peer while it holds the node lock, so a
+ * peer that accepts the connection but never answers (a stopped process, a lost host)
+ * stalls the election, and the answers to the other peers' votes, for the whole connect
+ * timeout. Installing a snapshot sends the whole store and needs far longer than a
+ * normal request.
+ */
+ static void setRpcTimeouts(NodeOptions nodeOptions, PDConfig.Raft config) {
+ nodeOptions.setRpcConnectTimeoutMs(config.getRpcConnectTimeout());
+ nodeOptions.setRpcDefaultTimeout(config.getRpcTimeout());
+ nodeOptions.setRpcInstallSnapshotTimeout(config.getRpcInstallSnapshotTimeout());
+ }
+
/**
* Create a Raft RPC Server for communication between PDs
*/
@@ -195,43 +218,68 @@ public List backChannelHandlers() {
}
}
- public void shutDown() {
- if (this.alivePeersRefresher != null) {
- this.alivePeersRefresher.shutdownNow();
- try {
- // Best effort: shutdownNow only interrupts, and a refresh parked in
- // listAlivePeers waits on a lock acquire the interrupt does not break,
- // for up to the raft rpc connect timeout per unreachable peer. A refresh
- // that outlives this wait may publish one stale count over the reset
- // below; acceptable while shutDown has no production caller.
- if (!this.alivePeersRefresher.awaitTermination(1, TimeUnit.SECONDS)) {
- log.warn("Raft alive-peers refresher still running after shutdown; " +
- "hg_raft_alive_peers may briefly report a stale value");
+ // Call while Raft can still complete metadata work already issued by a listener.
+ public void stopLeaderCallbacks() {
+ this.stateMachine.stopLeaderCallbacks();
+ }
+
+ public synchronized void shutDown() {
+ boolean interrupted = Thread.interrupted();
+ try {
+ if (this.alivePeersRefresher != null) {
+ this.alivePeersRefresher.shutdownNow();
+ while (!this.alivePeersRefresher.isTerminated()) {
+ try {
+ if (!this.alivePeersRefresher.awaitTermination(5, TimeUnit.SECONDS)) {
+ log.warn("Waiting for PD Raft alive-peers refresher to stop");
+ }
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
}
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
+ this.alivePeersRefresher = null;
}
- this.alivePeersRefresher = null;
- }
- this.alivePeerCount = -1;
- if (this.raftGroupService != null) {
- this.raftGroupService.shutdown();
- try {
- this.raftGroupService.join();
- } catch (final InterruptedException e) {
+ this.alivePeerCount = -1;
+ if (this.raftGroupService != null) {
+ this.raftGroupService.shutdown();
+ boolean joined = false;
+ while (!joined) {
+ try {
+ this.raftGroupService.join();
+ joined = true;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ this.raftGroupService = null;
+ this.raftNode = null;
+ } else if (this.raftNode != null) {
+ this.raftNode.shutdown();
+ boolean joined = false;
+ while (!joined) {
+ try {
+ this.raftNode.join();
+ joined = true;
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
this.raftNode = null;
- ThrowUtil.throwException(e);
}
- this.raftGroupService = null;
- }
- if (this.rpcServer != null) {
- this.rpcServer.shutdown();
- this.rpcServer = null;
- }
- if (this.raftNode != null) {
- this.raftNode.shutdown();
+ if (this.rpcServer != null) {
+ this.rpcServer.shutdown();
+ this.rpcServer = null;
+ }
+ if (this.raftRpcClient != null) {
+ this.raftRpcClient.shutdown();
+ this.raftRpcClient = null;
+ }
+ log.info("PD Raft shutdown joined");
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
}
- this.raftNode = null;
}
public boolean isLeader() {
@@ -602,6 +650,134 @@ public Node getRaftNode() {
return raftNode;
}
+ /**
+ * Wait until this node has applied every entry committed before the call, so a local
+ * read that follows sees every write acknowledged before it. jraft's ReadIndex confirms
+ * the commit index with a quorum in the current term and runs the closure once the
+ * applied index reaches it; a leader elected a moment ago first waits for an entry of
+ * its own term to commit, so EAGAIN and EBUSY are retried until the deadline. Bounded by
+ * the raft rpc timeout.
+ *
+ * Never call it on the state machine thread: the closure waits for that thread to apply.
+ */
+ public void waitReadIndex() throws PDException {
+ waitReadIndex(this.config.getRpcTimeout());
+ }
+
+ /**
+ * {@link #waitReadIndex()} for a read that a write depends on, such as an id counter.
+ * Returns the leader term, taken before the wait, for {@link Task#setExpectedTerm(long)}:
+ * jraft then rejects the write unless this node is still the leader of that term. A
+ * request that read in term 1 and paused could otherwise write its stale result after
+ * this node lost the leadership and won it back in term 3, over the writes of term 2.
+ */
+ public long waitReadIndexInTerm() throws PDException {
+ long term = this.stateMachine.getLeaderTerm();
+ waitReadIndex();
+ if (term > 0L) {
+ return term;
+ }
+ // A leader elected a moment ago is announced when the first entry of its term is
+ // applied, which the wait above waited for; wait again inside the announced term
+ term = this.stateMachine.getLeaderTerm();
+ if (term <= 0L) {
+ throw new PDException(Pdpb.ErrorType.NOT_LEADER_VALUE, "Not the raft leader");
+ }
+ waitReadIndex();
+ return term;
+ }
+
+ void waitReadIndex(long timeoutMs) throws PDException {
+ CompletableFuture future = new CompletableFuture<>();
+ long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
+ readIndex(future, deadline, 0);
+ try {
+ future.get(timeoutMs, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ // Stops a pending retry
+ future.cancel(false);
+ Thread.currentThread().interrupt();
+ throw new PDException(Pdpb.ErrorType.UNKNOWN_VALUE,
+ "Interrupted while waiting for the raft read index", e);
+ } catch (TimeoutException e) {
+ future.cancel(false);
+ throw new PDException(Pdpb.ErrorType.UNKNOWN_VALUE,
+ String.format("Raft read index timed out after %d ms",
+ timeoutMs));
+ } catch (ExecutionException e) {
+ if (e.getCause() instanceof PDException) {
+ throw (PDException) e.getCause();
+ }
+ throw new PDException(Pdpb.ErrorType.UNKNOWN_VALUE, e.getCause());
+ }
+ }
+
+ private void readIndex(CompletableFuture future, long deadline, int busyRetries) {
+ if (future.isDone()) {
+ return;
+ }
+ Node node = this.raftNode;
+ if (node == null) {
+ future.completeExceptionally(new PDException(Pdpb.ErrorType.NOT_LEADER_VALUE,
+ "Raft node is not started"));
+ return;
+ }
+ long remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
+ if (remaining <= 0L) {
+ // The waiter times out at the same deadline and reports how long it waited
+ return;
+ }
+ // Without a timeout the closure fails with ETIMEDOUT after jraft's own default
+ // (jraft.read-index.timeout, 2 s), before the caller's deadline
+ ReadIndexClosure closure = new ReadIndexClosure(remaining) {
+ @Override
+ public void run(Status status, long index, byte[] reqCtx) {
+ if (status.isOk()) {
+ future.complete(null);
+ return;
+ }
+ RaftError error = status.getRaftError();
+ if (error == RaftError.ETIMEDOUT) {
+ // The closure's own timer, set to the time left: the waiter reports it
+ return;
+ }
+ // EAGAIN: no entry of the current term committed yet. It clears once the new
+ // leader commits, which can take longer than any fixed number of short
+ // retries, so retry every 20 ms until the caller's deadline.
+ // EBUSY: leadership is being transferred, or jraft's read index queue is
+ // full. Retries at a fixed 20 ms would keep a full queue full, so each one
+ // waits twice as long as the one before it.
+ boolean busy = error == RaftError.EBUSY;
+ long delayMs = busy ? READ_INDEX_RETRY_DELAY_MS <<
+ Math.min(busyRetries, READ_INDEX_BUSY_MAX_SHIFT) :
+ READ_INDEX_RETRY_DELAY_MS;
+ long next = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delayMs);
+ if ((error == RaftError.EAGAIN || busy) && next - deadline < 0) {
+ int retries = busy ? busyRetries + 1 : 0;
+ READ_INDEX_RETRY.schedule(() -> readIndex(future, deadline, retries),
+ delayMs, TimeUnit.MILLISECONDS);
+ return;
+ }
+ // ENODESHUTDOWN and EHOSTDOWN come with the IllegalStateException caught
+ // below and may complete the future first, so they map the same way
+ int type = error == RaftError.EPERM || error == RaftError.ENODESHUTDOWN ||
+ error == RaftError.EHOSTDOWN ? Pdpb.ErrorType.NOT_LEADER_VALUE :
+ Pdpb.ErrorType.UNKNOWN_VALUE;
+ future.completeExceptionally(
+ new PDException(type, "Raft read index failed: " + status));
+ }
+ };
+ try {
+ node.readIndex(new byte[0], closure);
+ } catch (IllegalStateException e) {
+ // jraft throws once the node or its read service is shutting down, after
+ // queueing the closure with ENODESHUTDOWN or EHOSTDOWN; turn it into a
+ // PDException for getId's callers
+ future.completeExceptionally(new PDException(Pdpb.ErrorType.NOT_LEADER_VALUE,
+ "Raft node is shutting down", e));
+ }
+ }
+
private boolean peerEquals(PeerId p1, PeerId p2) {
if (p1 == null && p2 == null) {
return true;
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftRpcClient.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftRpcClient.java
index 8c7398a53a..18602101f0 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftRpcClient.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftRpcClient.java
@@ -45,6 +45,13 @@ public synchronized boolean init(final RpcOptions rpcOptions) {
return this.rpcClient.init(null);
}
+ public synchronized void shutdown() {
+ if (this.rpcClient != null) {
+ this.rpcClient.shutdown();
+ this.rpcClient = null;
+ }
+ }
+
/**
* Request a snapshot
*/
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java
index d07ab75f8c..40830e3af4 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java
@@ -80,6 +80,9 @@ static final class ProbeView {
}
private List taskHandlers;
private List stateListeners;
+ private final Object callbackMonitor = new Object();
+ private int callbacks;
+ private boolean stoppingCallbacks;
public RaftStateMachine() {
this.taskHandlers = new CopyOnWriteArrayList<>();
@@ -98,6 +101,13 @@ public boolean isLeader() {
return this.leaderTerm.get() > 0;
}
+ /**
+ * The term jraft last announced this node as the leader of, -1 while it is not the leader
+ */
+ long getLeaderTerm() {
+ return this.leaderTerm.get();
+ }
+
/**
* The last state and leader visibility a raft callback announced, as one immutable
* view. jraft emits no callback for candidacy or leadership transfer, so a candidate
@@ -107,6 +117,15 @@ ProbeView getProbeView() {
return this.probeView;
}
+ // Called only before starting a new node, after the previous node has joined.
+ void resetForNewNode() {
+ synchronized (this.callbackMonitor) {
+ this.leaderTerm.set(-1);
+ this.probeView = new ProbeView(State.STATE_UNINITIALIZED, false);
+ this.stoppingCallbacks = false;
+ }
+ }
+
@Override
public void onApply(Iterator iter) {
while (iter.hasNext()) {
@@ -150,37 +169,91 @@ public void onShutdown() {
super.onShutdown();
}
+ private void notifyStateListeners() {
+ synchronized (this.callbackMonitor) {
+ if (this.stoppingCallbacks || CollectionUtils.isEmpty(this.stateListeners)) {
+ return;
+ }
+ this.callbacks++;
+ }
+ try {
+ Utils.runInThread(() -> {
+ try {
+ this.stateListeners.forEach(RaftStateListener::onRaftLeaderChanged);
+ } finally {
+ callbackFinished();
+ }
+ });
+ } catch (RuntimeException | Error failure) {
+ callbackFinished();
+ throw failure;
+ }
+ }
+
+ private void callbackFinished() {
+ synchronized (this.callbackMonitor) {
+ this.callbacks--;
+ this.callbackMonitor.notifyAll();
+ }
+ }
+
+ void stopLeaderCallbacks() {
+ boolean interrupted = false;
+ synchronized (this.callbackMonitor) {
+ this.stoppingCallbacks = true;
+ while (this.callbacks != 0) {
+ try {
+ this.callbackMonitor.wait(5000L);
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ if (this.callbacks != 0) {
+ log.warn("Waiting for PD leader callbacks to finish");
+ }
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
@Override
public void onLeaderStart(final long term) {
+ if (isTerminal()) {
+ return;
+ }
this.leaderTerm.set(term);
this.probeView = new ProbeView(State.STATE_LEADER, true);
super.onLeaderStart(term);
log.info("Raft becomes leader");
- Utils.runInThread(() -> {
- if (!CollectionUtils.isEmpty(stateListeners)) {
- stateListeners.forEach(RaftStateListener::onRaftLeaderChanged);
- }
- });
+ notifyStateListeners();
}
@Override
public void onLeaderStop(final Status status) {
this.leaderTerm.set(-1);
- this.probeView = new ProbeView(State.STATE_FOLLOWER, false);
+ // JRaft steps down after reporting a fatal error. That callback does not recover the node.
+ if (!isTerminal()) {
+ this.probeView = new ProbeView(State.STATE_FOLLOWER, false);
+ }
super.onLeaderStop(status);
log.info("Raft lost leader ");
}
@Override
public void onStartFollowing(final LeaderChangeContext ctx) {
+ if (isTerminal()) {
+ return;
+ }
this.probeView = new ProbeView(State.STATE_FOLLOWER, true);
super.onStartFollowing(ctx);
- Utils.runInThread(() -> {
- if (!CollectionUtils.isEmpty(stateListeners)) {
- stateListeners.forEach(RaftStateListener::onRaftLeaderChanged);
- }
- });
+ notifyStateListeners();
+ }
+
+ private boolean isTerminal() {
+ State state = this.probeView.state;
+ return state == State.STATE_ERROR || state == State.STATE_SHUTDOWN;
}
@Override
@@ -195,45 +268,59 @@ public void onConfigurationCommitted(final Configuration conf) {
log.info("Raft onConfigurationCommitted {}", conf);
}
+ /**
+ * jraft calls this on the state machine thread, with the snapshot index set to the last
+ * applied index. The RocksDB checkpoint is taken here, before the thread applies the next
+ * entry: a checkpoint taken later holds entries past the snapshot index, and a node that
+ * installs the snapshot applies them a second time. Only the compression runs on the job
+ * thread. The state machine thread waits for the checkpoint, which is taken under the
+ * store's write lock: RocksDB flushes the memtable for it, copies the metadata files and
+ * hard-links the SST files, or copies them across filesystems. How long that pause is has
+ * not been measured.
+ */
@Override
public void onSnapshotSave(final SnapshotWriter writer, final Closure done) {
- MetadataService.getUninterruptibleJobs().submit(() -> {
- lock.lock();
+ String snapshotDir = writer.getPath() + File.separator + SNAPSHOT_DIR_NAME;
+ lock.lock();
+ try {
+ log.info("start snapshot save");
try {
- log.info("start snapshot save");
- String snapshotDir = writer.getPath() + File.separator + SNAPSHOT_DIR_NAME;
+ FileUtils.deleteDirectory(new File(snapshotDir));
+ FileUtils.forceMkdir(new File(snapshotDir));
+ } catch (IOException e) {
+ log.error("Failed to create snapshot directory {}", snapshotDir, e);
+ done.run(new Status(RaftError.EIO, e.toString()));
+ return;
+ }
+ for (RaftTaskHandler taskHandler : taskHandlers) {
try {
- FileUtils.deleteDirectory(new File(snapshotDir));
- FileUtils.forceMkdir(new File(snapshotDir));
- } catch (IOException e) {
- log.error("Failed to create snapshot directory {}", snapshotDir);
+ KVOperation op = KVOperation.createSaveSnapshot(snapshotDir);
+ taskHandler.invoke(op, null);
+ log.info("Raft onSnapshotSave success");
+ } catch (PDException e) {
+ log.error("Raft onSnapshotSave failed", e);
done.run(new Status(RaftError.EIO, e.toString()));
return;
}
- for (RaftTaskHandler taskHandler : taskHandlers) {
- try {
- KVOperation op = KVOperation.createSaveSnapshot(snapshotDir);
- taskHandler.invoke(op, null);
- log.info("Raft onSnapshotSave success");
- } catch (PDException e) {
- log.error("Raft onSnapshotSave failed. {}", e.toString());
- done.run(new Status(RaftError.EIO, e.toString()));
- }
- }
+ }
+ } catch (Exception e) {
+ log.error("failed to save snapshot", e);
+ done.run(new Status(RaftError.EIO, e.toString()));
+ return;
+ } finally {
+ lock.unlock();
+ }
+
+ MetadataService.getUninterruptibleJobs().submit(() -> {
+ lock.lock();
+ try {
// compress
- try {
- compressSnapshot(writer);
- FileUtils.deleteDirectory(new File(snapshotDir));
- } catch (Exception e) {
- log.error("Failed to delete snapshot directory {}, {}", snapshotDir,
- e.toString());
- done.run(new Status(RaftError.EIO, e.toString()));
- return;
- }
+ compressSnapshot(writer);
+ FileUtils.deleteDirectory(new File(snapshotDir));
done.run(Status.OK());
log.info("snapshot save done");
} catch (Exception e) {
- log.error("failed to save snapshot", e);
+ log.error("Failed to compress snapshot directory {}", snapshotDir, e);
done.run(new Status(RaftError.EIO, e.toString()));
} finally {
lock.unlock();
@@ -255,8 +342,10 @@ public boolean onSnapshotLoad(final SnapshotReader reader) {
try {
decompressSnapshot(reader);
} catch (PDException e) {
- log.error("Failed to decompress snapshot directory {}, {}", snapshotDir, e.toString());
- return true;
+ // false makes jraft fail the load. true would report the snapshot as
+ // installed and move the applied index to it, with none of its data loaded
+ log.error("Failed to decompress snapshot directory {}", snapshotDir, e);
+ return false;
}
CountDownLatch latch = new CountDownLatch(taskHandlers.size());
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStore.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStore.java
index 263cb70b28..f814df97cf 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStore.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStore.java
@@ -56,4 +56,35 @@ public interface HgKVStore {
List scanRange(byte[] start, byte[] end);
void close();
+
+ /**
+ * Wait until a local read sees every write committed before the call. A store that
+ * is not replicated has nothing to wait for.
+ */
+ default void waitReadIndex() throws PDException {
+ }
+
+ /**
+ * {@link #waitReadIndex()} for reads that a write depends on. Returns the leader term
+ * of the reads for the two methods below; -1 from a store that is not replicated.
+ */
+ default long waitReadIndexInTerm() throws PDException {
+ waitReadIndex();
+ return -1L;
+ }
+
+ /**
+ * A put that is applied only while this node is still the leader of {@code term}, and
+ * fails with NOT_LEADER otherwise: what was read in that term may be stale by then.
+ */
+ default void put(byte[] key, byte[] value, long term) throws PDException {
+ put(key, value);
+ }
+
+ /**
+ * A remove under the same condition
+ */
+ default long remove(byte[] key, long term) throws PDException {
+ return remove(key);
+ }
}
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStoreImpl.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStoreImpl.java
index bd2e7a9e22..f33648e0eb 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStoreImpl.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/HgKVStoreImpl.java
@@ -62,12 +62,11 @@ public class HgKVStoreImpl implements HgKVStore {
@Override
public void init(PDConfig config) {
- dbOptions = new Options().setCreateIfMissing(true);
-
final Lock writeLock = this.readWriteLock.writeLock();
writeLock.lock();
try {
- this.dbPath = config.getDataPath() + "/rocksdb/";
+ this.dbOptions = new Options().setCreateIfMissing(true);
+ this.dbPath = config.getDataPath() + "/rocksdb";
File file = new File(this.dbPath);
if (!file.exists()) {
try {
@@ -77,8 +76,14 @@ public void init(PDConfig config) {
}
}
openRocksDB(dbPath);
- } catch (PDException e) {
- log.error("Failed to open data file,{}", e);
+ } catch (PDException | RuntimeException | Error e) {
+ // TODO: retry opening a held RocksDB LOCK before failing PD startup.
+ // https://github.com/apache/hugegraph/issues/3226
+ if (this.dbOptions != null) {
+ this.dbOptions.close();
+ this.dbOptions = null;
+ }
+ throw new IllegalStateException("Failed to open PD metadata at " + this.dbPath, e);
} finally {
writeLock.unlock();
}
@@ -114,10 +119,10 @@ public byte[] get(byte[] key) throws PDException {
public List scanPrefix(byte[] prefix) {
final Lock readLock = this.readWriteLock.readLock();
readLock.lock();
- try (ReadOptions options = new ReadOptions()
- .setIterateLowerBound(new Slice(prefix))) {
+ try (Slice bound = new Slice(prefix);
+ ReadOptions options = new ReadOptions().setIterateLowerBound(bound);
+ RocksIterator iterator = db.newIterator(options)) {
List kvs = new ArrayList<>();
- RocksIterator iterator = db.newIterator(options);
iterator.seekToFirst();
while (iterator.isValid() && 0 == Bytes.indexOf(iterator.key(), prefix)) {
kvs.add(new KV(iterator.key(), iterator.value()));
@@ -147,9 +152,9 @@ public long remove(byte[] key) throws PDException {
public long removeByPrefix(byte[] prefix) throws PDException {
final Lock readLock = this.readWriteLock.readLock();
readLock.lock();
- try (ReadOptions options = new ReadOptions()
- .setIterateLowerBound(new Slice(prefix))) {
- RocksIterator iterator = db.newIterator(options);
+ try (Slice bound = new Slice(prefix);
+ ReadOptions options = new ReadOptions().setIterateLowerBound(bound);
+ RocksIterator iterator = db.newIterator(options)) {
iterator.seekToFirst();
while (iterator.isValid()) {
@@ -304,11 +309,11 @@ public void loadSnapshot(String snapshotPath) throws PDException {
public List scanRange(byte[] start, byte[] end) {
final Lock readLock = this.readWriteLock.readLock();
readLock.lock();
- try (ReadOptions options = new ReadOptions()
- .setIterateLowerBound(new Slice(start))
- .setIterateUpperBound(new Slice(end))) {
+ try (Slice lower = new Slice(start);
+ Slice upper = new Slice(end);
+ ReadOptions options = new ReadOptions().setIterateLowerBound(lower).setIterateUpperBound(upper);
+ RocksIterator iterator = db.newIterator(options)) {
List kvs = new ArrayList<>();
- RocksIterator iterator = db.newIterator(options);
iterator.seekToFirst();
while (iterator.isValid()) {
kvs.add(new KV(iterator.key(), iterator.value()));
@@ -322,7 +327,17 @@ public List scanRange(byte[] start, byte[] end) {
@Override
public void close() {
- closeRocksDB();
+ Lock writeLock = this.readWriteLock.writeLock();
+ writeLock.lock();
+ try {
+ closeRocksDB();
+ if (this.dbOptions != null) {
+ this.dbOptions.close();
+ this.dbOptions = null;
+ }
+ } finally {
+ writeLock.unlock();
+ }
}
private void closeRocksDB() {
diff --git a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/RaftKVStore.java b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/RaftKVStore.java
index b61f07ac1d..47ebad864f 100644
--- a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/RaftKVStore.java
+++ b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/store/RaftKVStore.java
@@ -20,6 +20,7 @@
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import org.apache.hugegraph.pd.common.PDException;
@@ -179,6 +180,44 @@ public void close() {
store.close();
}
+ /**
+ * Reads above are served from the local state, which on a new leader can trail the
+ * entries its predecessor committed; this waits for them to be applied
+ */
+ @Override
+ public void waitReadIndex() throws PDException {
+ this.engine.waitReadIndex();
+ }
+
+ @Override
+ public long waitReadIndexInTerm() throws PDException {
+ return this.engine.waitReadIndexInTerm();
+ }
+
+ @Override
+ public void put(byte[] key, byte[] value, long term) throws PDException {
+ applyInTerm(KVOperation.createPut(key, value), term);
+ }
+
+ @Override
+ public long remove(byte[] key, long term) throws PDException {
+ applyInTerm(KVOperation.createRemove(key), term);
+ return 0;
+ }
+
+ private void applyInTerm(KVOperation op, long term) throws PDException {
+ try {
+ applyOperation(op, term).get();
+ } catch (ExecutionException e) {
+ // Keeps NOT_LEADER, which is how jraft's EPERM for another term arrives here
+ throw e.getCause() instanceof PDException ? (PDException) e.getCause() :
+ new PDException(Pdpb.ErrorType.UNKNOWN_VALUE, e.getCause());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new PDException(Pdpb.ErrorType.UNKNOWN_VALUE, e);
+ }
+ }
+
/**
* Need to walk the real operation of Raft
*/
@@ -221,9 +260,16 @@ public void doLoadSnapshot(String snapshotPath) throws PDException {
}
private CompletableFuture applyOperation(final KVOperation op) throws PDException {
+ // -1 is jraft's default: the entry is accepted in any term
+ return applyOperation(op, -1L);
+ }
+
+ private CompletableFuture applyOperation(final KVOperation op, long expectedTerm)
+ throws PDException {
CompletableFuture future = new CompletableFuture<>();
try {
final Task task = new Task();
+ task.setExpectedTerm(expectedTerm);
task.setData(ByteBuffer.wrap(op.toByteArray()));
task.setDone(new RaftStateMachine.RaftClosureAdapter(op, new KVStoreClosure() {
Object data;
diff --git a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh
index 77af2af89d..f3bf1437e1 100755
--- a/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh
+++ b/hugegraph-pd/hg-pd-dist/docker/docker-entrypoint.sh
@@ -22,7 +22,17 @@ log() { echo "[hugegraph-pd-entrypoint] $*"; }
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
- echo "ERROR: missing required env '${name}'" >&2; exit 2
+ echo "ERROR: missing required env '${name}'" >&2
+ if [[ "${name}" == "HG_PD_AUTH_SECRET_KEY" ]]; then
+ echo "This shared credential configures PD REST Basic authentication (auth.secret-key)." >&2
+ echo "Helm normally creates a shared Secret through pd.auth.autoGenerate=true and injects it" >&2
+ echo "into HG_PD_AUTH_SECRET_KEY via the PD StatefulSet env.valueFrom.secretKeyRef." >&2
+ echo "Check pd.auth.value or pd.auth.existingSecret and pd.auth.key, then verify the" >&2
+ echo "rendered StatefulSet env and the referenced Secret name/key in the release namespace." >&2
+ echo "For standalone containers, set HG_PD_AUTH_SECRET_KEY to the same value on every PD" >&2
+ echo "and use that value for Server PD_AUTH_PASSWORD. Do not generate a separate value per node." >&2
+ fi
+ exit 2
fi
}
diff --git a/hugegraph-pd/hg-pd-dist/src/assembly/descriptor/server-assembly.xml b/hugegraph-pd/hg-pd-dist/src/assembly/descriptor/server-assembly.xml
index c43a868352..ca6439ae7b 100644
--- a/hugegraph-pd/hg-pd-dist/src/assembly/descriptor/server-assembly.xml
+++ b/hugegraph-pd/hg-pd-dist/src/assembly/descriptor/server-assembly.xml
@@ -41,6 +41,24 @@
+
+
+ ${project.basedir}/../../hugegraph-server/hugegraph-dist/src/assembly/static/bin/prepare-topling.sh
+ bin
+ 755
+
+
+ ${project.basedir}/../../hugegraph-server/hugegraph-dist/src/assembly/static/bin/preload-topling.sh
+ bin
+ 755
+
+
+ ${project.basedir}/../../hugegraph-server/hugegraph-dist/src/assembly/static/conf/toplingdb.yaml
+ conf
+ 644
+
+
+
diff --git a/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/jvm-module.options b/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/jvm-module.options
new file mode 100644
index 0000000000..b41209f448
--- /dev/null
+++ b/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/jvm-module.options
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+
+# Hessian reflects into StackTraceElement when serializing Raft operations.
+--add-opens=java.base/java.lang=ALL-UNNAMED
diff --git a/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/start-hugegraph-pd.sh b/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/start-hugegraph-pd.sh
index 1329df2271..d687db42cb 100755
--- a/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/start-hugegraph-pd.sh
+++ b/hugegraph-pd/hg-pd-dist/src/assembly/static/bin/start-hugegraph-pd.sh
@@ -37,7 +37,7 @@ while getopts "d:g:j:y:" arg; do
# Telemetry is used to collect metrics, traces and logs
d) DAEMON="$OPTARG" ;;
y) OPEN_TELEMETRY="$OPTARG" ;;
- ?) echo "USAGE: $0 [-d true|false] [-g g1] [-j xxx] [-y true|false]" && exit 1 ;;
+ ?) echo "USAGE: $0 [-d true|false] [-g g1|ZGC] [-j xxx] [-y true|false]" && exit 1 ;;
esac
done
@@ -60,6 +60,12 @@ LOGS="$TOP/logs"
OUTPUT=${LOGS}/hugegraph-pd-stdout.log
GITHUB="https://github.com"
PID_FILE="$BIN/pid"
+JVM_MODULE_OPTIONS="$BIN/jvm-module.options"
+
+if [[ ! -f "$JVM_MODULE_OPTIONS" || ! -r "$JVM_MODULE_OPTIONS" ]]; then
+ echo "Error: JVM module options file is missing or unreadable: $JVM_MODULE_OPTIONS" >&2
+ exit 1
+fi
. "$BIN"/util.sh
@@ -69,7 +75,7 @@ ensure_path_writable "$PLUGINS"
# The maximum and minimum heap memory that service can use
MAX_MEM=$((32 * 1024))
MIN_MEM=$((1 * 512))
-EXPECT_JDK_VERSION=11
+EXPECT_JDK_VERSION=17
# Change to $BIN's parent
cd "${TOP}" || exit
@@ -82,8 +88,11 @@ else
fi
# check jdk version
-JAVA_VERSION=$($JAVA -version 2>&1 | awk 'NR==1{gsub(/"/,""); print $3}' | awk -F'_' '{print $1}')
-if [[ $? -ne 0 || $JAVA_VERSION < $EXPECT_JDK_VERSION ]]; then
+JAVA_VERSION=$($JAVA -version 2>&1 |
+ awk -F'"' '/^(java|openjdk) version "/ {print $2; exit}' |
+ sed 's/^1\.//' | cut -d'.' -f1)
+JAVA_VERSION="${JAVA_VERSION%%[!0-9]*}"
+if [[ -z $JAVA_VERSION || $JAVA_VERSION -lt $EXPECT_JDK_VERSION ]]; then
echo "Please make sure that the JDK is installed and the version >= $EXPECT_JDK_VERSION" >> ${OUTPUT}
exit 1
fi
@@ -103,22 +112,27 @@ if [ "$JAVA_OPTIONS" = "" ]; then
# -Xloggc:./logs/gc.log -XX:+PrintHeapAtGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps"
fi
-# Using G1GC as the default garbage collector (Recommended for large memory machines)
+# Keep JVM/caller GC selection by default; explicitly select G1 when requested.
case "$GC_OPTION" in
- "")
- echo "Using G1GC as the default garbage collector"
- JAVA_OPTIONS="${JAVA_OPTIONS} -XX:+ParallelRefProcEnabled \
- -XX:InitiatingHeapOccupancyPercent=50 -XX:G1RSetUpdatingPauseTimePercent=5"
+ ""|g1|G1)
+ if [[ "$GC_OPTION" == g1 || "$GC_OPTION" == G1 ]]; then
+ echo "Using G1GC"
+ JAVA_OPTIONS="${JAVA_OPTIONS} -XX:+UseG1GC"
+ else
+ echo "Using JVM garbage collector configuration"
+ fi
+ JAVA_OPTIONS="-XX:+ParallelRefProcEnabled -XX:InitiatingHeapOccupancyPercent=50 \
+ -XX:G1RSetUpdatingPauseTimePercent=5 ${JAVA_OPTIONS}"
;;
zgc|ZGC)
- echo "Using ZGC as the default garbage collector (Only support Java 11+)"
+ echo "Using ZGC as the default garbage collector (requires Java 17 or later)"
JAVA_OPTIONS="${JAVA_OPTIONS} -XX:+UseZGC -XX:+UnlockExperimentalVMOptions \
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6 \
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5 \
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive"
;;
*)
- echo "Unrecognized gc option: '$GC_OPTION', default use g1, options only support 'ZGC' now" >> ${OUTPUT}
+ echo "Unrecognized gc option: '$GC_OPTION', supported options: g1, ZGC" >> ${OUTPUT}
exit 1
esac
@@ -170,15 +184,26 @@ fi
JVM_OPTIONS="-Dlog4j.configurationFile=${CONF}/log4j2.xml -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager"
+source "$BIN/preload-topling.sh" || exit 1
+BOOT_JARS=("${LIB}"/hg-pd-service-*.jar)
+if [ "${#BOOT_JARS[@]}" -ne 1 ] || [ ! -f "${BOOT_JARS[0]}" ]; then
+ echo "Error: expected one component executable JAR in $LIB" >&2
+ exit 1
+fi
+JAVA_MAIN=(-jar "${BOOT_JARS[0]}")
+if [ -n "${TOPLING_RUNTIME_CLASSPATH:-}" ]; then
+ JAVA_MAIN=(-cp "${TOPLING_RUNTIME_CLASSPATH}:${BOOT_JARS[0]}" org.springframework.boot.loader.JarLauncher)
+fi
+
# Turn on security check
if [[ $DAEMON == "true" ]]; then
echo "Starting HugeGraphPDServer in daemon mode..."
if [[ "${STDOUT_MODE:-false}" == "true" ]]; then
- exec ${JAVA} -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} -jar \
- -Dspring.config.location=${CONF}/application.yml ${LIB}/hg-pd-service-*.jar &
+ exec ${JAVA} "@$JVM_MODULE_OPTIONS" -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} \
+ -Dspring.config.location=${CONF}/application.yml "${JAVA_MAIN[@]}" "$@" &
else
- exec ${JAVA} -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} -jar \
- -Dspring.config.location=${CONF}/application.yml ${LIB}/hg-pd-service-*.jar >> ${OUTPUT} 2>&1 &
+ exec ${JAVA} "@$JVM_MODULE_OPTIONS" -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} \
+ -Dspring.config.location=${CONF}/application.yml "${JAVA_MAIN[@]}" "$@" >> ${OUTPUT} 2>&1 &
fi
PID="$!"
# Write pid to file
@@ -190,10 +215,10 @@ else
echo "$$" > "$PID_FILE"
echo "[+pid] $$"
if [[ "${STDOUT_MODE:-false}" == "true" ]]; then
- exec ${JAVA} -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} -jar \
- -Dspring.config.location=${CONF}/application.yml ${LIB}/hg-pd-service-*.jar
+ exec ${JAVA} "@$JVM_MODULE_OPTIONS" -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} \
+ -Dspring.config.location=${CONF}/application.yml "${JAVA_MAIN[@]}" "$@"
else
- exec ${JAVA} -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} -jar \
- -Dspring.config.location=${CONF}/application.yml ${LIB}/hg-pd-service-*.jar >> ${OUTPUT} 2>&1
+ exec ${JAVA} "@$JVM_MODULE_OPTIONS" -Dname="HugeGraphPD" ${JVM_OPTIONS} ${JAVA_OPTIONS} \
+ -Dspring.config.location=${CONF}/application.yml "${JAVA_MAIN[@]}" "$@" >> ${OUTPUT} 2>&1
fi
fi
diff --git a/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml b/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml
index ada9791922..73bcae9069 100644
--- a/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml
+++ b/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml
@@ -96,3 +96,7 @@ partition:
# The default maximum number of replicas per machine
# the initial number of partitions= store-max-shard-count * store-number / default-shard-count
store-max-shard-count: 12
+
+# Match the explicit launcher opt-in when enabling Topling.
+rocksdb:
+ provider: rocksdb
diff --git a/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml.template b/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml.template
index 43bdf99fc4..1fc2fc4fea 100644
--- a/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml.template
+++ b/hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml.template
@@ -63,6 +63,12 @@ raft:
address: $RAFT_ADDRESS$
# raft cluster
peers-list: $RAFT_PEERS_LIST$
+ # Timeout for opening a connection to a peer, in milliseconds; an election waits this
+ # long for each peer that accepts the connection but does not answer
+ rpc-connect-timeout: 1000
+ # Timeout for sending a snapshot to a follower that fell behind the log, in milliseconds;
+ # 0 uses rpc-timeout
+ rpc-install-snapshot-timeout: 0
# The interval between snapshot generation, in seconds
snapshotInterval: 300
metrics: true
diff --git a/hugegraph-pd/hg-pd-grpc/pom.xml b/hugegraph-pd/hg-pd-grpc/pom.xml
index be8ccc44fd..4c6b77abbd 100644
--- a/hugegraph-pd/hg-pd-grpc/pom.xml
+++ b/hugegraph-pd/hg-pd-grpc/pom.xml
@@ -33,7 +33,8 @@
1.7.1
- 1.39.0
+
+ 1.39.03.17.20.6.1
@@ -90,7 +91,7 @@
grpc-java
- io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}
+ io.grpc:protoc-gen-grpc-java:${grpc.codegen.version}:exe:${os.detected.classifier}
${project.basedir}/src/main/proto${project.basedir}/src/main/java
@@ -138,14 +139,13 @@
- 1.47.0
- 3.21.7
+ 1.47.0com.google.protobufprotobuf-java
- ${riscv64.protobuf.java.version}
+ ${protobuf.runtime.version}
diff --git a/hugegraph-pd/hg-pd-service/pom.xml b/hugegraph-pd/hg-pd-service/pom.xml
index eef38b4073..7332c1210b 100644
--- a/hugegraph-pd/hg-pd-service/pom.xml
+++ b/hugegraph-pd/hg-pd-service/pom.xml
@@ -57,16 +57,29 @@
${revision}
+
- org.apache.hugegraph
- hg-store-common
- ${revision}
+ org.apache.commons
+ commons-text
+ 1.10.0
- org.apache.hugegraph
- hugegraph-core
- ${revision}
+ io.jsonwebtoken
+ jjwt-api
+ 0.11.5
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.11.5
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.11.5
+ runtime
@@ -83,7 +96,7 @@
org.springframework.bootspring-boot-starter-web
- 2.5.14
+ ${spring-boot.version}
@@ -101,7 +114,7 @@
org.springframework.bootspring-boot-starter-actuator
- 2.5.14
+ ${spring-boot.version}io.micrometer
@@ -111,12 +124,12 @@
org.springframework.bootspring-boot-starter-jetty
- 2.5.14
+ ${spring-boot.version}org.springframework.bootspring-boot-starter-log4j2
- 2.5.14
+ ${spring-boot.version}log4j-slf4j-impl
@@ -137,7 +150,7 @@
com.google.protobufprotobuf-java-util
- 3.17.2
+ ${protobuf.runtime.version}org.apache.hugegraph
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/HugePDServer.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/HugePDServer.java
index 54d2761df8..2ca7f7540c 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/HugePDServer.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/HugePDServer.java
@@ -17,6 +17,7 @@
package org.apache.hugegraph.pd.boot;
+import org.apache.hugegraph.util.RocksDBRuntime;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
@@ -37,8 +38,10 @@ public static void main(String[] args) {
System.setProperty("logging.path", "logs");
System.setProperty("com.alipay.remoting.client.log.level", "error");
}
- Runtime.getRuntime().addShutdownHook(new ShutdownHook(Thread.currentThread()));
- SpringApplication.run(HugePDServer.class);
+ SpringApplication application = new SpringApplication(HugePDServer.class);
+ application.addInitializers(context -> RocksDBRuntime.verify(
+ context.getEnvironment().getProperty("rocksdb.provider", "rocksdb")));
+ application.run(args);
System.out.println("Hugegraph-pd started.");
}
}
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/ShutdownHook.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/ShutdownHook.java
index 371235f341..a8cf28abbf 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/ShutdownHook.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/boot/ShutdownHook.java
@@ -15,75 +15,120 @@
* limitations under the License.
*/
-
package org.apache.hugegraph.pd.boot;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hugegraph.pd.TaskScheduleService;
+import org.apache.hugegraph.pd.meta.MetadataFactory;
+import org.apache.hugegraph.pd.raft.RaftEngine;
+import org.apache.hugegraph.pd.pulse.PDPulseSubject;
+import org.apache.hugegraph.pd.service.KvServiceGrpcImpl;
import org.apache.hugegraph.pd.service.MetadataService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.apache.hugegraph.pd.service.PDService;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.context.ApplicationListener;
+import org.lognet.springboot.grpc.context.GRpcServerInitializedEvent;
-import java.util.concurrent.ThreadPoolExecutor;
+import io.grpc.Server;
+import org.springframework.stereotype.Component;
+
+import lombok.extern.slf4j.Slf4j;
-public class ShutdownHook extends Thread {
+/**
+ * Spring stops its transports before destroying singleton beans. The PDService
+ * dependency keeps its metadata owners available until this cleanup completes.
+ */
+@Slf4j
+@Component
+public class ShutdownHook implements DisposableBean, ApplicationListener {
- private static Logger log = LoggerFactory.getLogger(ShutdownHook.class);
- private static String msg = "there are still uninterruptible jobs that have not been completed and" +
- " will wait for them to complete";
- private Thread main;
+ private final PDService service;
+ private final KvServiceGrpcImpl kvService;
+ private Server grpcServer;
- public ShutdownHook(Thread main) {
- super();
- this.main = main;
+ public ShutdownHook(PDService service, KvServiceGrpcImpl kvService) {
+ this.service = service;
+ this.kvService = kvService;
}
@Override
- public void run() {
- log.info("shutdown signal received");
- main.interrupt();
- waitForShutdown();
- try {
- main.join();
- } catch (InterruptedException e) {
- }
- log.info("shutdown completed");
+ public void onApplicationEvent(GRpcServerInitializedEvent event) {
+ this.grpcServer = event.getServer();
}
- private void waitForShutdown() {
- checkUninterruptibleJobs();
+ @Override
+ public void destroy() {
+ // The starter's default stop initiates shutdown without waiting.
+ awaitGrpcTermination();
+ this.kvService.stopScheduling();
+ closeOwners(this.service);
}
- private void checkUninterruptibleJobs() {
- ThreadPoolExecutor jobs = MetadataService.getUninterruptibleJobs();
+ public static void closeOwners(PDService service) {
+ log.info("Closing PD background work and native owners");
+ TaskScheduleService tasks = service.getTaskService();
+ if (tasks != null) {
+ tasks.shutDown();
+ }
+ PDPulseSubject.stopScheduling();
+ RaftEngine raft = RaftEngine.getInstance();
+ raft.stopLeaderCallbacks();
+ // Raft may still submit snapshot saves while its shutdown is joining.
+ raft.shutDown();
+ awaitUninterruptibleJobs();
+ // A failed join must not release the database out from under Raft.
+ MetadataFactory.shutdownStore();
+ log.info("PD native owners closed");
+ }
+
+ private void awaitGrpcTermination() {
+ if (this.grpcServer == null) {
+ return;
+ }
+ // End long-lived watch/pulse streams as well as accepting no new calls.
+ this.grpcServer.shutdownNow();
+ boolean interrupted = Thread.interrupted();
try {
- if (jobs != null) {
- long lastPrint = System.currentTimeMillis() - 5000;
- log.info("check for ongoing background jobs that cannot be interrupted, active:{}, queue:{}.",
- jobs.getActiveCount(), jobs.getQueue().size());
- while (jobs.getActiveCount() != 0 || jobs.getQueue().size() != 0) {
- synchronized (ShutdownHook.class) {
- if (System.currentTimeMillis() - lastPrint > 5000) {
- log.warn(msg);
- lastPrint = System.currentTimeMillis();
- }
- try {
- ShutdownHook.class.wait(200);
- } catch (InterruptedException e) {
- log.error("close jobs with error:", e);
- }
+ while (!this.grpcServer.isTerminated()) {
+ try {
+ if (!this.grpcServer.awaitTermination(5, TimeUnit.SECONDS)) {
+ log.warn("Waiting for PD gRPC transport to terminate");
}
+ } catch (InterruptedException e) {
+ interrupted = true;
}
- log.info("all ongoing background jobs have been completed and the shutdown will continue");
}
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
- } catch (Exception e) {
- log.error("close jobs with error:", e);
+ private static void awaitUninterruptibleJobs() {
+ ThreadPoolExecutor jobs = MetadataService.getUninterruptibleJobs();
+ if (jobs == null) {
+ return;
}
+ jobs.shutdown();
+ boolean interrupted = Thread.interrupted();
try {
- if (jobs != null) {
- jobs.shutdownNow();
+ while (!jobs.isTerminated()) {
+ try {
+ if (!jobs.awaitTermination(5, TimeUnit.SECONDS)) {
+ log.warn("Waiting for PD metadata jobs, active: {}, queued: {}",
+ jobs.getActiveCount(), jobs.getQueue().size());
+ }
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
}
- } catch (Exception e) {
- log.error("close jobs with error:", e);
}
}
}
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java
index 793aa24b05..62957ee76d 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java
@@ -56,6 +56,7 @@ public final class PDMetrics {
private MeterRegistry registry;
private Map> lasts = new ConcurrentHashMap();
private int interval = 120 * 1000;
+ private StoreStateMetrics storeStateMetrics;
public synchronized void init(MeterRegistry meterRegistry) {
@@ -71,6 +72,11 @@ private void registerMeters() {
Gauge.builder(PREFIX + ".graphs", () -> updateGraphs())
.description("Number of graphs registered in PD")
.register(registry);
+ // PDService creates its StoreNodeService in @PostConstruct, so look it up per read
+ this.storeStateMetrics = new StoreStateMetrics(
+ registry,
+ () -> this.pdService.getStoreNodeService().getStores(),
+ () -> this.pdService.getStoreNodeService().getShardGroups());
Gauge.builder(PREFIX + ".stores", () -> updateStores())
.description("Number of stores registered in PD")
.register(registry);
@@ -111,23 +117,14 @@ private long updateGraphs() {
}
private long updateStores() {
- return getStores();
+ // Also registers the per-store gauges of stores that joined since the last scrape
+ return this.storeStateMetrics.storeCount();
}
private long getGraphs() {
return getGraphMetas().size();
}
- private long getStores() {
- try {
- return this.pdService.getStoreNodeService().getStores(null).size();
- } catch (PDException e) {
- log.error(e.getMessage(), e);
- e.printStackTrace();
- }
- return 0;
- }
-
private long setTerms() {
List groups = null;
try {
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/StoreStateMetrics.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/StoreStateMetrics.java
new file mode 100644
index 0000000000..4181a34de1
--- /dev/null
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/StoreStateMetrics.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.pd.metrics;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hugegraph.pd.common.PDException;
+import org.apache.hugegraph.pd.grpc.Metapb;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.Meter;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Per-store gauges built from the store and shard group metadata PD already keeps:
+ * {@code hg.store.state{store,state}} is 1 for the store's current state and 0 for the
+ * others, and {@code hg.store.leader.count{store}} counts the shard groups it leads.
+ * Stores are registered and removed at runtime, so the meters follow the store list on
+ * every refresh. The gauges, and {@code hg.stores} through {@link #storeCount()}, trigger
+ * the refresh themselves at most once per {@link #REFRESH_INTERVAL_MS}, so one scrape reads
+ * the metadata once.
+ */
+@Slf4j
+final class StoreStateMetrics {
+
+ static final long REFRESH_INTERVAL_MS = 1000;
+
+ @FunctionalInterface
+ interface Source {
+
+ T get() throws PDException;
+ }
+
+ private final MeterRegistry registry;
+ private final Source> stores;
+ private final Source> shardGroups;
+ // Guarded by this
+ private final Map> meters = new HashMap<>();
+ private long lastRefresh;
+
+ private volatile Map states = Collections.emptyMap();
+ private volatile Map leaders = Collections.emptyMap();
+
+ StoreStateMetrics(MeterRegistry registry, Source> stores,
+ Source> shardGroups) {
+ this.registry = registry;
+ this.stores = stores;
+ this.shardGroups = shardGroups;
+ }
+
+ /**
+ * Reads the store list and shard groups, registers meters for new stores and removes
+ * those of stores PD no longer knows. On a read error the previous values are kept.
+ */
+ synchronized void refresh() {
+ List storeList;
+ List groups;
+ try {
+ storeList = this.stores.get();
+ groups = this.shardGroups.get();
+ } catch (PDException | RuntimeException e) {
+ // RuntimeException: a scrape can arrive before PDService finishes init
+ log.warn("Failed to read stores for store state metrics", e);
+ return;
+ }
+ this.lastRefresh = System.currentTimeMillis();
+
+ Map newStates = new HashMap<>();
+ Map newLeaders = new HashMap<>();
+ for (Metapb.Store store : storeList) {
+ newStates.put(store.getId(), store.getState());
+ newLeaders.put(store.getId(), 0);
+ }
+ for (Metapb.ShardGroup group : groups) {
+ for (Metapb.Shard shard : group.getShardsList()) {
+ if (shard.getRole() == Metapb.ShardRole.Leader &&
+ newLeaders.containsKey(shard.getStoreId())) {
+ newLeaders.merge(shard.getStoreId(), 1, Integer::sum);
+ }
+ }
+ }
+ this.states = newStates;
+ this.leaders = newLeaders;
+
+ for (Long storeId : newStates.keySet()) {
+ this.meters.computeIfAbsent(storeId, this::registerStore);
+ }
+ Iterator>> iter = this.meters.entrySet().iterator();
+ while (iter.hasNext()) {
+ Map.Entry> entry = iter.next();
+ if (!newStates.containsKey(entry.getKey())) {
+ entry.getValue().forEach(this.registry::remove);
+ iter.remove();
+ }
+ }
+ }
+
+ synchronized void refreshIfStale() {
+ if (System.currentTimeMillis() - this.lastRefresh >= REFRESH_INTERVAL_MS) {
+ this.refresh();
+ }
+ }
+
+ private double state(long storeId, Metapb.StoreState state) {
+ this.refreshIfStale();
+ return this.states.get(storeId) == state ? 1 : 0;
+ }
+
+ /**
+ * The number of stores PD had at the last refresh, which hg.stores reports so a scrape
+ * reads the store list once
+ */
+ long storeCount() {
+ this.refreshIfStale();
+ return this.states.size();
+ }
+
+ private double leaderCount(long storeId) {
+ this.refreshIfStale();
+ Integer count = this.leaders.get(storeId);
+ return count == null ? Double.NaN : count;
+ }
+
+ private List registerStore(long storeId) {
+ String store = String.valueOf(storeId);
+ List list = new ArrayList<>();
+ for (Metapb.StoreState state : Metapb.StoreState.values()) {
+ if (state == Metapb.StoreState.UNRECOGNIZED) {
+ continue;
+ }
+ list.add(Gauge.builder(PDMetrics.PREFIX + ".store.state", this,
+ m -> m.state(storeId, state))
+ .description("1 if the store is in this state, 0 otherwise")
+ .tag("store", store)
+ .tag("state", state.name())
+ .register(this.registry));
+ }
+ list.add(Gauge.builder(PDMetrics.PREFIX + ".store.leader.count", this,
+ m -> m.leaderCount(storeId))
+ .description("Number of shard groups whose leader is on the store")
+ .tag("store", store)
+ .register(this.registry));
+ return list;
+ }
+}
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/pulse/PDPulseSubject.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/pulse/PDPulseSubject.java
index 86b95cb880..58d886abee 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/pulse/PDPulseSubject.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/pulse/PDPulseSubject.java
@@ -89,6 +89,27 @@ public class PDPulseSubject {
TimeUnit.SECONDS);
}
+ /** Stop periodic queue work and wait while Raft can still serve its writes. */
+ public static void stopScheduling() {
+ scheduledExecutor.shutdown();
+ boolean interrupted = Thread.interrupted();
+ try {
+ while (!scheduledExecutor.isTerminated()) {
+ try {
+ if (!scheduledExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
+ log.warn("Waiting for PD pulse scheduler to stop");
+ }
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
private static void doSchedule() {
appendQueue();
expireQueue();
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java
index 4fcf3660f5..e4fea9d16e 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java
@@ -17,7 +17,6 @@
package org.apache.hugegraph.pd.rest;
-import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
@@ -49,6 +48,7 @@
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
+import com.alipay.sofa.jraft.core.State;
import com.google.protobuf.util.JsonFormat;
import lombok.Data;
@@ -388,16 +388,22 @@ class StoreStatistics {
* This interface is used to check the health status of the service by accessing the /health
* path via a GET request.
*
- * This is a liveness signal only: it answers 200 as soon as the REST listener is up and
- * does not consult the raft state. Use {@link #checkReady()} to find out whether this PD
- * can actually serve.
+ * This liveness signal answers 503 for a fatal raft error or a stopped state machine.
+ * Elections and temporary quorum loss remain healthy. Use {@link #checkReady()} to find
+ * out whether this PD can actually serve.
*
* @return Returns a string indicating the service's health status. Typically, an empty
* string indicates the service is healthy.
*/
+ // TODO: bounded metadata-store LOCK retry is tracked separately in
+ // https://github.com/apache/hugegraph/issues/3226; this probe reads only Raft state.
@GetMapping(value = "/health", produces = MediaType.TEXT_PLAIN_VALUE)
- public Serializable checkHealthy() {
- return "";
+ public ResponseEntity checkHealthy() {
+ String state = RaftEngine.getInstance().getRaftStatus().getState();
+ boolean failed = State.STATE_ERROR.name().equals(state) ||
+ State.STATE_SHUTDOWN.name().equals(state);
+ return ResponseEntity.status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK)
+ .body("");
}
/**
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/TaskAPI.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/TaskAPI.java
index 60aad2d554..eaa53908d1 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/TaskAPI.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/TaskAPI.java
@@ -89,6 +89,12 @@ public String splitPartitions() {
}
}
+ // TODO: make the task routes tell a real run from a no-op and a follower answer (a follower
+ // returns an empty success without doing anything), and return a body that names what was
+ // scheduled. Part 1 (#3233) replaced the bare 500 with a refusal body; this is part 2. The
+ // Helm chart (helm/hugegraph) documents the leader-first sequence and the 180 s spacing in its
+ // README until then.
+ // https://github.com/apache/hugegraph/issues/3231
@GetMapping(value = "/balanceLeaders", produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public String balanceLeaders() {
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/KvServiceGrpcImpl.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/KvServiceGrpcImpl.java
index e49a4dc48b..94b41a0f0a 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/KvServiceGrpcImpl.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/KvServiceGrpcImpl.java
@@ -26,6 +26,7 @@
import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
import org.apache.hugegraph.pd.KvService;
import org.apache.hugegraph.pd.common.PDException;
@@ -86,6 +87,32 @@ public void init() {
}, 0, KvWatchSubject.WATCH_TTL * 1 / 3, TimeUnit.MILLISECONDS);
}
+ /** Stop periodic watch work and wait while Raft can still serve its writes. */
+ @PreDestroy
+ public void stopScheduling() {
+ ScheduledExecutorService current = this.executor;
+ if (current == null) {
+ return;
+ }
+ current.shutdown();
+ boolean interrupted = Thread.interrupted();
+ try {
+ while (!current.isTerminated()) {
+ try {
+ if (!current.awaitTermination(5, TimeUnit.SECONDS)) {
+ log.warn("Waiting for PD KV watch scheduler to stop");
+ }
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ } finally {
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
/**
* Ordinary put
*
diff --git a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/PDService.java b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/PDService.java
index 94d136a844..f40b2f5801 100644
--- a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/PDService.java
+++ b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/service/PDService.java
@@ -53,6 +53,7 @@
import org.apache.hugegraph.pd.StoreNodeService;
import org.apache.hugegraph.pd.StoreStatusListener;
import org.apache.hugegraph.pd.TaskScheduleService;
+import org.apache.hugegraph.pd.boot.ShutdownHook;
import org.apache.hugegraph.pd.common.KVPair;
import org.apache.hugegraph.pd.common.PDException;
import org.apache.hugegraph.pd.config.PDConfig;
@@ -177,6 +178,22 @@ public void setInitConfig(PDConfig pdConfig) {
*/
@PostConstruct
public void init() throws PDException {
+ try {
+ initService();
+ } catch (PDException | RuntimeException | Error failure) {
+ // Failed @PostConstruct beans are not registered for destruction.
+ try {
+ ShutdownHook.closeOwners(this);
+ } catch (RuntimeException | Error cleanup) {
+ if (cleanup != failure) {
+ failure.addSuppressed(cleanup);
+ }
+ }
+ throw failure;
+ }
+ }
+
+ private void initService() throws PDException {
log.info("PDService init………… {}", pdConfig);
configService = new ConfigService(pdConfig);
diff --git a/hugegraph-pd/hg-pd-service/src/main/resources/application.yml b/hugegraph-pd/hg-pd-service/src/main/resources/application.yml
index f8c1ecea84..fb44a3a4c1 100644
--- a/hugegraph-pd/hg-pd-service/src/main/resources/application.yml
+++ b/hugegraph-pd/hg-pd-service/src/main/resources/application.yml
@@ -72,6 +72,12 @@ raft:
peers-list: 127.0.0.1:8610
# The read and write timeout period of the raft rpc, in milliseconds
rpc-timeout: 10000
+ # Timeout for opening a connection to a peer, in milliseconds; an election waits this
+ # long for each peer that accepts the connection but does not answer
+ rpc-connect-timeout: 1000
+ # Timeout for sending a snapshot to a follower that fell behind the log, in milliseconds;
+ # 0 uses rpc-timeout
+ rpc-install-snapshot-timeout: 0
# The interval between snapshot generation, in seconds
snapshotInterval: 300
metrics: true
diff --git a/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/metrics/StoreStateMetricsTest.java b/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/metrics/StoreStateMetricsTest.java
new file mode 100644
index 0000000000..79a01e13af
--- /dev/null
+++ b/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/metrics/StoreStateMetricsTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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.pd.metrics;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.hugegraph.pd.common.PDException;
+import org.apache.hugegraph.pd.grpc.Metapb;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import io.micrometer.prometheus.PrometheusConfig;
+import io.micrometer.prometheus.PrometheusMeterRegistry;
+
+public class StoreStateMetricsTest {
+
+ private SimpleMeterRegistry registry;
+ private List stores;
+ private List groups;
+ private boolean failRead;
+ private StoreStateMetrics metrics;
+
+ @Before
+ public void setup() {
+ this.registry = new SimpleMeterRegistry();
+ this.stores = new ArrayList<>();
+ this.groups = new ArrayList<>();
+ this.failRead = false;
+ this.metrics = new StoreStateMetrics(this.registry, () -> {
+ if (this.failRead) {
+ throw new PDException(-1, "raft is not ready");
+ }
+ return this.stores;
+ }, () -> this.groups);
+ }
+
+ @Test
+ public void testStateAndLeaderCount() {
+ this.stores.add(store(1, Metapb.StoreState.Up));
+ this.stores.add(store(2, Metapb.StoreState.Up));
+ this.stores.add(store(3, Metapb.StoreState.Offline));
+ // store 3 went down, so both groups elected leaders on store 1
+ this.groups.add(group(1, 1, 2, 3));
+ this.groups.add(group(2, 1, 2, 3));
+ this.metrics.refresh();
+
+ Assert.assertEquals(1.0, state(1, "Up"), 0);
+ Assert.assertEquals(0.0, state(1, "Offline"), 0);
+ Assert.assertEquals(1.0, state(3, "Offline"), 0);
+ Assert.assertEquals(0.0, state(3, "Up"), 0);
+ Assert.assertEquals(2.0, leaders(1), 0);
+ Assert.assertEquals(0.0, leaders(2), 0);
+ Assert.assertEquals(0.0, leaders(3), 0);
+ // One gauge per state except UNRECOGNIZED, for each store
+ int states = Metapb.StoreState.values().length - 1;
+ Assert.assertEquals(3 * states,
+ this.registry.find("hg.store.state").gauges().size());
+ }
+
+ @Test
+ public void testStoresJoinAndLeave() {
+ this.stores.add(store(1, Metapb.StoreState.Up));
+ this.metrics.refresh();
+ Assert.assertNull(gauge("hg.store.leader.count", 2));
+
+ this.stores.add(store(2, Metapb.StoreState.Pending));
+ this.metrics.refresh();
+ Assert.assertEquals(1.0, state(2, "Pending"), 0);
+
+ this.stores.remove(0);
+ this.metrics.refresh();
+ Assert.assertNull(gauge("hg.store.leader.count", 1));
+ Assert.assertTrue(this.registry.find("hg.store.state").tag("store", "1")
+ .gauges().isEmpty());
+ Assert.assertNotNull(gauge("hg.store.leader.count", 2));
+ }
+
+ @Test
+ public void testReadErrorKeepsLastValues() {
+ this.stores.add(store(1, Metapb.StoreState.Up));
+ this.metrics.refresh();
+
+ this.failRead = true;
+ this.stores.clear();
+ this.metrics.refresh();
+ Assert.assertEquals(1.0, state(1, "Up"), 0);
+ }
+
+ @Test
+ public void testPrometheusScrape() {
+ var prometheus = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
+ var reads = new AtomicInteger();
+ var scraped = new StoreStateMetrics(prometheus, () -> {
+ reads.incrementAndGet();
+ return this.stores;
+ }, () -> this.groups);
+ // As hg.stores in PDMetrics: the refresh registers meters while a scrape runs
+ Gauge.builder("hg.stores", scraped::storeCount).register(prometheus);
+ this.stores.add(store(7, Metapb.StoreState.Up));
+ this.groups.add(group(1, 7));
+
+ prometheus.scrape();
+ String body = prometheus.scrape();
+ // Both scrapes fall in one refresh interval, and hg.stores reuses the refreshed list
+ Assert.assertEquals(1, reads.get());
+ Assert.assertTrue(body, body.contains("hg_stores 1.0"));
+ Assert.assertTrue(body, body.contains("hg_store_state{state=\"Up\",store=\"7\",} 1.0"));
+ Assert.assertTrue(body,
+ body.contains("hg_store_state{state=\"Offline\",store=\"7\",} 0.0"));
+ Assert.assertTrue(body, body.contains("hg_store_leader_count{store=\"7\",} 1.0"));
+ }
+
+ private double state(long storeId, String state) {
+ return this.registry.get("hg.store.state")
+ .tag("store", String.valueOf(storeId))
+ .tag("state", state)
+ .gauge().value();
+ }
+
+ private double leaders(long storeId) {
+ return gauge("hg.store.leader.count", storeId).value();
+ }
+
+ private Gauge gauge(String name, long storeId) {
+ return this.registry.find(name).tag("store", String.valueOf(storeId)).gauge();
+ }
+
+ private static Metapb.Store store(long id, Metapb.StoreState state) {
+ return Metapb.Store.newBuilder().setId(id).setState(state).build();
+ }
+
+ private static Metapb.ShardGroup group(int id, long leader, long... followers) {
+ var builder = Metapb.ShardGroup.newBuilder().setId(id);
+ builder.addShards(Metapb.Shard.newBuilder().setStoreId(leader)
+ .setRole(Metapb.ShardRole.Leader));
+ for (long follower : followers) {
+ if (follower != leader) {
+ builder.addShards(Metapb.Shard.newBuilder().setStoreId(follower)
+ .setRole(Metapb.ShardRole.Follower));
+ }
+ }
+ return builder.build();
+ }
+}
diff --git a/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/rest/StoreAPIReadyTest.java b/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/rest/StoreAPIReadyTest.java
index 75e9581cb1..1391715fef 100644
--- a/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/rest/StoreAPIReadyTest.java
+++ b/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/rest/StoreAPIReadyTest.java
@@ -35,6 +35,7 @@
import com.alipay.sofa.jraft.core.State;
import com.alipay.sofa.jraft.entity.LeaderChangeContext;
import com.alipay.sofa.jraft.entity.PeerId;
+import com.alipay.sofa.jraft.error.RaftException;
/**
* Pins the status a readiness probe actually receives from {@code GET /v1/ready}: 503 while
@@ -120,6 +121,83 @@ private static LeaderChangeContext context() {
return new LeaderChangeContext(LEADER, 5, Status.OK());
}
+ @Test
+ public void testFatalErrorSurvivesLeaderStopAndAnswers503() {
+ Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", untouchableNode());
+ this.stateMachine.onLeaderStart(5);
+ this.stateMachine.onError(new RaftException());
+ this.stateMachine.onLeaderStop(new Status(-1, "snapshot failed"));
+
+ Assert.assertEquals(HttpStatus.SERVICE_UNAVAILABLE,
+ this.api.checkHealthy().getStatusCode());
+ Assert.assertEquals("", this.api.checkHealthy().getBody());
+ ResponseEntity
@@ -204,11 +166,24 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20
+
+
+ @{argLine}
+ "@${project.basedir}/../hg-pd-dist/src/assembly/static/bin/jvm-module.options"
+
+
+
+
+ org.apache.maven.surefire
+ surefire-junit4
+ ${maven.surefire.plugin.version}
+
+ pd-client-test
+ true${basedir}/src/main/java/
${basedir}/target/classes/
@@ -221,6 +196,7 @@
pd-core-test
+ true${basedir}/src/main/java/
${basedir}/target/classes/
@@ -233,6 +209,7 @@
pd-common-test
+ true${basedir}/src/main/java/
${basedir}/target/classes/
@@ -245,6 +222,7 @@
pd-rest-test
+ true${basedir}/src/main/java/
${basedir}/target/classes/
@@ -259,7 +237,6 @@
org.jacocojacoco-maven-plugin
- 0.8.8org/apache/hugegraph/pd/rest/*.class
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/client/PDClientTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/client/PDClientTest.java
index 3676122612..3e7bbda39a 100644
--- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/client/PDClientTest.java
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/client/PDClientTest.java
@@ -21,7 +21,7 @@
import java.util.ArrayList;
import java.util.List;
-import org.apache.tinkerpop.shaded.minlog.Log;
+import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
@@ -323,14 +323,12 @@ public void testChangePeerList() {
@Test
public void testSplitData() {
try {
- Metapb.PDConfig config = pdClient.getPDConfig();
- pdClient.setPDConfig(config.toBuilder()
- .setMaxShardsPerStore(12)
- .build());
- System.out.println(pdClient.getPDConfig());
pdClient.splitData();
+ Assert.fail("Expected splitData() to reject an unready cluster");
} catch (PDException e) {
- Log.error("testSplitData", e);
+ Assert.assertEquals(
+ Pdpb.ErrorType.Cluster_State_Forbid_Splitting_VALUE,
+ e.getErrorCode());
}
}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/LogServiceTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/LogServiceTest.java
index 9588d6d171..0aea282cb8 100644
--- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/LogServiceTest.java
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/LogServiceTest.java
@@ -40,11 +40,11 @@ public void setUp() {
public void testGetLog() throws Exception {
this.logServiceUnderTest.insertLog("action", "message", Any.newBuilder().build());
- // Run the test
+ // The query end is exclusive, so include logs inserted in the current millisecond.
final List result =
- this.logServiceUnderTest.getLog("action", 0L, System.currentTimeMillis());
+ this.logServiceUnderTest.getLog("action", 0L, System.currentTimeMillis() + 1L);
// Verify the results
- Assert.assertEquals(result.size(), 1);
+ Assert.assertEquals(1, result.size());
}
}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java
index ddc1fad7f9..c0c15975e8 100644
--- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java
@@ -17,12 +17,21 @@
package org.apache.hugegraph.pd.core;
+import org.apache.hugegraph.pd.core.meta.IdMetaStoreReadIndexTest;
import org.apache.hugegraph.pd.core.meta.MetadataKeyHelperTest;
import org.apache.hugegraph.pd.core.store.HgKVStoreImplTest;
+import org.apache.hugegraph.pd.core.store.RaftKVStoreTest;
import org.apache.hugegraph.pd.raft.IpAuthHandlerTest;
+import org.apache.hugegraph.pd.raft.KVOperationTest;
+import org.apache.hugegraph.pd.raft.PeerUtilTest;
import org.apache.hugegraph.pd.raft.RaftEngineIpAuthIntegrationTest;
import org.apache.hugegraph.pd.raft.RaftEngineLeaderAddressTest;
+import org.apache.hugegraph.pd.raft.RaftEngineReadIndexTest;
import org.apache.hugegraph.pd.raft.RaftEngineReadinessTest;
+import org.apache.hugegraph.pd.raft.RaftEngineRpcTimeoutTest;
+import org.apache.hugegraph.pd.raft.RaftReflectionUtilTest;
+import org.apache.hugegraph.pd.raft.RaftStateMachineSnapshotTest;
+import org.apache.hugegraph.pd.raft.ZipUtilsTest;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
@@ -32,7 +41,9 @@
@Suite.SuiteClasses({
RaftStateMachineTest.class,
MetadataKeyHelperTest.class,
+ IdMetaStoreReadIndexTest.class,
HgKVStoreImplTest.class,
+ PDNativeOwnerTest.class,
PDConfigTest.class,
ConfigServiceTest.class,
IdServiceTest.class,
@@ -46,6 +57,14 @@
RaftEngineIpAuthIntegrationTest.class,
RaftEngineLeaderAddressTest.class,
RaftEngineReadinessTest.class,
+ RaftReflectionUtilTest.class,
+ KVOperationTest.class,
+ PeerUtilTest.class,
+ ZipUtilsTest.class,
+ RaftKVStoreTest.class,
+ RaftEngineReadIndexTest.class,
+ RaftStateMachineSnapshotTest.class,
+ RaftEngineRpcTimeoutTest.class,
// StoreNodeServiceTest.class,
})
@Slf4j
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDNativeOwnerTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDNativeOwnerTest.java
new file mode 100644
index 0000000000..e7c6c1d050
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDNativeOwnerTest.java
@@ -0,0 +1,226 @@
+/*
+ * 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.pd.core;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.nio.file.Path;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.ReadWriteLock;
+
+import org.apache.hugegraph.pd.config.PDConfig;
+import org.apache.hugegraph.pd.meta.MetadataFactory;
+import org.apache.hugegraph.pd.raft.RaftEngine;
+import org.apache.hugegraph.pd.raft.RaftRpcClient;
+import org.apache.hugegraph.pd.raft.RaftStateMachine;
+import org.apache.hugegraph.pd.store.HgKVStore;
+import org.apache.hugegraph.pd.store.HgKVStoreImpl;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.rocksdb.Options;
+import org.rocksdb.RocksDB;
+
+import com.alipay.sofa.jraft.Node;
+import com.alipay.sofa.jraft.RaftGroupService;
+import com.alipay.sofa.jraft.rpc.RpcServer;
+
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class PDNativeOwnerTest {
+
+ @Rule
+ public TemporaryFolder temporary = new TemporaryFolder();
+
+ @Test(timeout = 10000)
+ public void testCloseWaitsForReadersAndReleasesNativeOwners() throws Exception {
+ RocksDB.loadLibrary();
+ Path path = this.temporary.newFolder().toPath();
+ PDConfig config = new PDConfig();
+ config.setDataPath(path.toString());
+ HgKVStoreImpl store = new HgKVStoreImpl();
+ store.init(config);
+ RocksDB db = Whitebox.getInternalState(store, "db");
+ Options options = Whitebox.getInternalState(store, "dbOptions");
+ ReadWriteLock lock = Whitebox.getInternalState(store, "readWriteLock");
+ store.put(new byte[]{1}, new byte[]{2});
+ store.put(new byte[]{3}, new byte[]{4});
+ Assert.assertEquals(1, store.scanPrefix(new byte[]{1}).size());
+ Assert.assertEquals(2, store.scanRange(new byte[]{0}, new byte[]{4}).size());
+ store.removeByPrefix(new byte[]{1});
+ Assert.assertEquals(0, store.scanPrefix(new byte[]{1}).size());
+ CountDownLatch closing = new CountDownLatch(1);
+ FutureTask close = new FutureTask<>(() -> {
+ closing.countDown();
+ store.close();
+ return null;
+ });
+ Thread closer = new Thread(close, "pd-owner-close");
+ lock.readLock().lock();
+ try {
+ closer.start();
+ Assert.assertTrue(closing.await(1, TimeUnit.SECONDS));
+ Assert.assertThrows(TimeoutException.class, () -> close.get(50, TimeUnit.MILLISECONDS));
+ Assert.assertTrue(db.isOwningHandle());
+ Assert.assertTrue(options.isOwningHandle());
+ } finally {
+ lock.readLock().unlock();
+ }
+ close.get(2, TimeUnit.SECONDS);
+ store.close();
+ Assert.assertFalse(db.isOwningHandle());
+ Assert.assertFalse(options.isOwningHandle());
+ HgKVStoreImpl reopened = new HgKVStoreImpl();
+ reopened.init(config);
+ try {
+ Assert.assertArrayEquals(new byte[]{4}, reopened.get(new byte[]{3}));
+ } finally {
+ reopened.close();
+ }
+ }
+
+ @Test
+ public void testInitializationFailureReleasesOptions() {
+ RocksDB.loadLibrary();
+ HgKVStoreImpl store = new HgKVStoreImpl();
+ PDConfig config = mock(PDConfig.class);
+ AtomicReference allocated = new AtomicReference<>();
+ IllegalStateException injected = new IllegalStateException("injected configuration failure");
+ when(config.getDataPath()).thenAnswer(invocation -> {
+ allocated.set(Whitebox.getInternalState(store, "dbOptions"));
+ throw injected;
+ });
+ IllegalStateException failure = Assert.assertThrows(IllegalStateException.class, () -> store.init(config));
+ Assert.assertSame(injected, failure.getCause());
+ Assert.assertFalse(allocated.get().isOwningHandle());
+ store.close();
+ }
+
+ @Test
+ public void testTerminalFactoryCloseRejectsLazyReopen() throws Exception {
+ Field storeField = MetadataFactory.class.getDeclaredField("store");
+ Field shutdownField = MetadataFactory.class.getDeclaredField("shutdown");
+ storeField.setAccessible(true);
+ shutdownField.setAccessible(true);
+ synchronized (MetadataFactory.class) {
+ Object previous = storeField.get(null);
+ boolean shutdown = shutdownField.getBoolean(null);
+ HgKVStore owned = mock(HgKVStore.class);
+ try {
+ storeField.set(null, owned);
+ shutdownField.setBoolean(null, false);
+ MetadataFactory.shutdownStore();
+ MetadataFactory.shutdownStore();
+ verify(owned, times(1)).close();
+ Assert.assertThrows(IllegalStateException.class, () -> MetadataFactory.getStore(null));
+ } finally {
+ storeField.set(null, previous);
+ shutdownField.setBoolean(null, shutdown);
+ }
+ }
+ }
+
+ @Test
+ public void testInterruptedRaftJoinRetainsOwnerAndClosesRpcClient() throws Exception {
+ RaftEngine engine = newEngine();
+ RaftGroupService group = mock(RaftGroupService.class);
+ Node node = mock(Node.class);
+ RpcServer server = mock(RpcServer.class);
+ RaftRpcClient client = mock(RaftRpcClient.class);
+ Whitebox.setInternalState(engine, "raftGroupService", group);
+ Whitebox.setInternalState(engine, "raftNode", node);
+ Whitebox.setInternalState(engine, "rpcServer", server);
+ Whitebox.setInternalState(engine, "raftRpcClient", client);
+ AtomicInteger attempts = new AtomicInteger();
+ doAnswer(invocation -> {
+ Assert.assertSame(node, engine.getRaftNode());
+ if (attempts.incrementAndGet() == 1) {
+ throw new InterruptedException("injected join interruption");
+ }
+ return null;
+ }).when(group).join();
+ try {
+ engine.shutDown();
+ Assert.assertTrue(Thread.interrupted());
+ Assert.assertEquals(2, attempts.get());
+ Assert.assertNull(engine.getRaftNode());
+ engine.shutDown();
+ verify(group, times(1)).shutdown();
+ verify(node, never()).shutdown();
+ verify(server, times(1)).shutdown();
+ verify(client, times(1)).shutdown();
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test(timeout = 10000)
+ public void testLeaderCallbacksDrainBeforeStoppingRaft() throws Exception {
+ RaftEngine engine = newEngine();
+ RaftStateMachine state = Whitebox.getInternalState(engine, "stateMachine");
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicInteger calls = new AtomicInteger();
+ state.addStateListener(() -> {
+ calls.incrementAndGet();
+ entered.countDown();
+ try {
+ Assert.assertTrue(release.await(3, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ throw new AssertionError(e);
+ }
+ });
+ state.onLeaderStart(1);
+ Assert.assertTrue(entered.await(1, TimeUnit.SECONDS));
+ FutureTask stop = new FutureTask<>(() -> {
+ engine.stopLeaderCallbacks();
+ return null;
+ });
+ Thread stopper = new Thread(stop, "pd-leader-drain");
+ try {
+ stopper.start();
+ Assert.assertThrows(TimeoutException.class, () -> stop.get(50, TimeUnit.MILLISECONDS));
+ release.countDown();
+ stop.get(2, TimeUnit.SECONDS);
+ state.onLeaderStart(2);
+ engine.stopLeaderCallbacks();
+ Assert.assertEquals(1, calls.get());
+ } finally {
+ release.countDown();
+ stopper.join(3000);
+ }
+ }
+
+ private static RaftEngine newEngine() throws Exception {
+ Constructor constructor = RaftEngine.class.getDeclaredConstructor();
+ constructor.setAccessible(true);
+ return constructor.newInstance();
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/meta/IdMetaStoreReadIndexTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/meta/IdMetaStoreReadIndexTest.java
new file mode 100644
index 0000000000..e3d9dfb2ad
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/meta/IdMetaStoreReadIndexTest.java
@@ -0,0 +1,380 @@
+/*
+ * 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.pd.core.meta;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.hugegraph.pd.common.PDException;
+import org.apache.hugegraph.pd.config.PDConfig;
+import org.apache.hugegraph.pd.grpc.Pdpb;
+import org.apache.hugegraph.pd.meta.IdMetaStore;
+import org.apache.hugegraph.pd.meta.MetadataFactory;
+import org.apache.hugegraph.pd.raft.KVOperation;
+import org.apache.hugegraph.pd.raft.RaftEngine;
+import org.apache.hugegraph.pd.store.HgKVStore;
+import org.apache.hugegraph.pd.store.KV;
+import org.apache.hugegraph.pd.store.RaftKVStore;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.InOrder;
+
+import com.alipay.sofa.jraft.Status;
+import com.alipay.sofa.jraft.entity.Task;
+import com.alipay.sofa.jraft.error.RaftError;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link IdMetaStore#getId} and both {@code getCId} overloads read local state and then write
+ * through raft as two steps, so the read must follow a raft ReadIndex: a leader elected a
+ * moment ago may not have applied its predecessor's last write yet, and would hand out the
+ * same id twice. The write must also carry the leader term of the read: a request that pauses
+ * between the two could otherwise write its stale result after the node lost the leadership
+ * and won it back.
+ */
+public class IdMetaStoreReadIndexTest {
+
+ private HgKVStore originalStore;
+ private HgKVStore store;
+ private IdMetaStore idMetaStore;
+
+ @Before
+ public void setUp() {
+ // The metadata stores share one factory-held store; swap in a mock for this test
+ this.originalStore = Whitebox.getInternalState(MetadataFactory.class, "store");
+ this.store = mock(HgKVStore.class);
+ Whitebox.setInternalState(MetadataFactory.class, "store", this.store);
+ this.idMetaStore = new IdMetaStore(new PDConfig());
+ }
+
+ @After
+ public void tearDown() {
+ Whitebox.setInternalState(MetadataFactory.class, "store", this.originalStore);
+ }
+
+ @Test
+ public void testGetIdReadsOnlyAfterTheReadIndex() throws PDException {
+ when(this.store.waitReadIndexInTerm()).thenReturn(7L);
+ when(this.store.get(any())).thenReturn(IdMetaStore.longToBytes(100L));
+
+ Assert.assertEquals(100L, this.idMetaStore.getId("read-index", 10));
+
+ InOrder order = inOrder(this.store);
+ order.verify(this.store).waitReadIndexInTerm();
+ order.verify(this.store).get(any());
+ // The write carries the term of the read
+ order.verify(this.store).put(any(), any(), eq(7L));
+ verify(this.store, never()).put(any(), any());
+ }
+
+ @Test
+ public void testGetIdLeavesTheCounterAloneWhenTheReadIndexFails() throws PDException {
+ doThrow(new PDException(Pdpb.ErrorType.NOT_LEADER_VALUE, "not leader"))
+ .when(this.store).waitReadIndexInTerm();
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ this.idMetaStore.getId("read-index", 10);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ verify(this.store, times(1)).waitReadIndexInTerm();
+ verify(this.store, never()).get(any());
+ verify(this.store, never()).put(any(), any());
+ verify(this.store, never()).put(any(), any(), anyLong());
+ }
+
+ @Test
+ public void testGetCIdReadsOnlyAfterTheReadIndex() throws PDException {
+ when(this.store.waitReadIndexInTerm()).thenReturn(7L);
+ when(this.store.get(any())).thenReturn(IdMetaStore.longToBytes(5L));
+
+ Assert.assertEquals(5L, this.idMetaStore.getCId("read-index", 16));
+
+ InOrder order = inOrder(this.store);
+ order.verify(this.store).waitReadIndexInTerm();
+ order.verify(this.store).get(any());
+ order.verify(this.store).scanRange(any(), any());
+ order.verify(this.store, times(2)).put(any(), any(), eq(7L));
+ verify(this.store, never()).put(any(), any());
+ }
+
+ @Test
+ public void testGetCIdByNameReadsTheDelayedSlotOnlyAfterTheReadIndex()
+ throws PDException {
+ // A minimal in-memory store, so the delayed slot is written the way PD writes it
+ Map data = new HashMap<>();
+ doAnswer(inv -> data.put(ByteBuffer.wrap(inv.getArgument(0)), inv.getArgument(1)))
+ .when(this.store).put(any(), any());
+ when(this.store.get(any())).thenAnswer(inv -> {
+ return data.get(ByteBuffer.wrap(inv.getArgument(0)));
+ });
+ this.idMetaStore.delCIdDelay("read-index", "graph", 7L);
+ clearInvocations(this.store);
+ when(this.store.waitReadIndexInTerm()).thenReturn(7L);
+
+ Assert.assertEquals(7L, this.idMetaStore.getCId("read-index", "graph", 16));
+
+ InOrder order = inOrder(this.store);
+ order.verify(this.store).waitReadIndexInTerm();
+ order.verify(this.store).scanPrefix(any());
+ order.verify(this.store).get(any());
+ order.verify(this.store).remove(any(), eq(7L));
+ verify(this.store, times(1)).waitReadIndexInTerm();
+ verify(this.store, never()).remove(any());
+ verify(this.store, never()).put(any(), any());
+ verify(this.store, never()).put(any(), any(), anyLong());
+ }
+
+ @Test
+ public void testGetCIdByNameWithoutADelayedSlotWaitsOnce() throws PDException {
+ // A second wait would return the second term
+ when(this.store.waitReadIndexInTerm()).thenReturn(7L, 8L);
+
+ Assert.assertEquals(0L, this.idMetaStore.getCId("read-index", "graph", 16));
+
+ // The new cid is written in the term of the wait that covered the delayed slot
+ verify(this.store, times(1)).waitReadIndexInTerm();
+ verify(this.store, times(2)).put(any(), any(), eq(7L));
+ verify(this.store, never()).put(any(), any(), eq(8L));
+ verify(this.store, never()).put(any(), any());
+ }
+
+ @Test
+ public void testGetCIdLeavesTheSlotsAloneWhenTheReadIndexFails() throws PDException {
+ doThrow(new PDException(Pdpb.ErrorType.NOT_LEADER_VALUE, "not leader"))
+ .when(this.store).waitReadIndexInTerm();
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ this.idMetaStore.getCId("read-index", 16);
+ });
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ e = Assert.assertThrows(PDException.class, () -> {
+ this.idMetaStore.getCId("read-index", "graph", 16);
+ });
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+
+ verify(this.store, never()).get(any());
+ verify(this.store, never()).scanPrefix(any());
+ verify(this.store, never()).scanRange(any(), any());
+ verify(this.store, never()).put(any(), any());
+ verify(this.store, never()).put(any(), any(), anyLong());
+ verify(this.store, never()).remove(any());
+ verify(this.store, never()).remove(any(), anyLong());
+ }
+
+ @Test
+ public void testRaftStoreWaitsOnTheRaftEngine() throws PDException {
+ RaftEngine engine = mock(RaftEngine.class);
+ HgKVStore local = mock(HgKVStore.class);
+
+ new RaftKVStore(engine, local).waitReadIndex();
+
+ verify(engine).waitReadIndex();
+ }
+
+ @Test
+ public void testRaftStoreBindsOnlyATermWriteToItsTerm() throws PDException {
+ RaftEngine engine = mock(RaftEngine.class);
+ when(engine.waitReadIndexInTerm()).thenReturn(7L);
+ List expectedTerms = new ArrayList<>();
+ doAnswer(inv -> {
+ Task task = inv.getArgument(0);
+ expectedTerms.add(task.getExpectedTerm());
+ task.getDone().run(Status.OK());
+ return null;
+ }).when(engine).addTask(any());
+ RaftKVStore raftStore = new RaftKVStore(engine, mock(HgKVStore.class));
+
+ Assert.assertEquals(7L, raftStore.waitReadIndexInTerm());
+ raftStore.put(new byte[]{1}, new byte[]{1}, 7L);
+ raftStore.remove(new byte[]{1}, 7L);
+ // -1 is jraft's "any term": the writes that follow no read stay as they were
+ raftStore.put(new byte[]{1}, new byte[]{1});
+ raftStore.remove(new byte[]{1});
+
+ Assert.assertEquals(List.of(7L, 7L, -1L, -1L), expectedTerms);
+ }
+
+ @Test
+ public void testCounterDoesNotRollBackAcrossALeaderChangeAndBack() throws PDException {
+ FencedNode nodeA = new FencedNode();
+ IdMetaStore allocator = nodeA.allocator();
+ // The request on A reads counter 0 in term 1 and pauses before its put. B leads
+ // term 2 and allocates [0,10) and [10,20). A applies both and leads again in term 3.
+ nodeA.pauseBeforeFirstWrite(() -> {
+ nodeA.term.set(2L);
+ Assert.assertEquals(0L, allocator.getId("aba", 10));
+ Assert.assertEquals(10L, allocator.getId("aba", 10));
+ nodeA.term.set(3L);
+ });
+
+ // Written in term 3 from the read of term 1, the put would set the counter back
+ // from 20 to 10 and return [0,10) a second time
+ Assert.assertEquals(20L, allocator.getId("aba", 10));
+
+ // The put of term 1 was rejected and the allocation read again in term 3
+ Assert.assertEquals(List.of(1L), nodeA.rejectedTerms);
+ Assert.assertEquals(List.of(2L, 2L, 3L), nodeA.acceptedTerms);
+ // A new request does not get [10,20) or [20,30) a second time
+ Assert.assertEquals(30L, allocator.getId("aba", 10));
+ }
+
+ @Test
+ public void testCyclicIdIsNotHandedOutTwiceAcrossALeaderChangeAndBack()
+ throws PDException {
+ FencedNode nodeA = new FencedNode();
+ IdMetaStore allocator = nodeA.allocator();
+ // A reads counter 0 and no slot in term 1 and pauses. B leads term 2 and hands out
+ // the cids 0 and 1. A applies that and leads again in term 3.
+ nodeA.pauseBeforeFirstWrite(() -> {
+ nodeA.term.set(2L);
+ Assert.assertEquals(0L, allocator.getCId("aba", 16));
+ Assert.assertEquals(1L, allocator.getCId("aba", 16));
+ nodeA.term.set(3L);
+ });
+
+ // Not 0, which B handed out in term 2
+ Assert.assertEquals(2L, allocator.getCId("aba", 16));
+
+ Assert.assertEquals(List.of(1L), nodeA.rejectedTerms);
+ Assert.assertEquals(3L, allocator.getCId("aba", 16));
+ }
+
+ @Test
+ public void testAllocationGivesUpWhenItsWritesKeepBeingRejected() throws PDException {
+ when(this.store.waitReadIndexInTerm()).thenReturn(7L);
+ doThrow(new PDException(Pdpb.ErrorType.NOT_LEADER_VALUE, "another term"))
+ .when(this.store).put(any(), any(), anyLong());
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ this.idMetaStore.getId("read-index", 10);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ verify(this.store, times(3)).waitReadIndexInTerm();
+ verify(this.store, times(3)).put(any(), any(), eq(7L));
+ }
+
+ @Test
+ public void testAllocationDoesNotRepeatAWriteThatFailedForAnotherReason()
+ throws PDException {
+ doThrow(new PDException(Pdpb.ErrorType.UNKNOWN_VALUE, "apply failed"))
+ .when(this.store).put(any(), any(), anyLong());
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ this.idMetaStore.getId("read-index", 10);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.UNKNOWN_VALUE, e.getErrorCode());
+ verify(this.store, times(1)).put(any(), any(), anyLong());
+ }
+
+ /**
+ * PD node A as the allocator sees it: the production {@link IdMetaStore} and
+ * {@link RaftKVStore} over a raft engine that accepts a task the way jraft's
+ * {@code NodeImpl.executeApplyingTasks} does, only when the task's expected term is -1
+ * or the current term, and applies an accepted task to the local state at once.
+ */
+ private static final class FencedNode {
+
+ private final TreeMap applied = new TreeMap<>(Arrays::compareUnsigned);
+ private final AtomicLong term = new AtomicLong(1L);
+ private final List acceptedTerms = new ArrayList<>();
+ private final List rejectedTerms = new ArrayList<>();
+ private final HgKVStore local = mock(HgKVStore.class);
+ private final RaftEngine engine = mock(RaftEngine.class);
+ private final RaftKVStore raftStore = new RaftKVStore(this.engine, this.local);
+ private Pause beforeFirstWrite;
+
+ private FencedNode() throws PDException {
+ when(this.local.get(any())).thenAnswer(inv -> {
+ return this.applied.get(inv.getArgument(0));
+ });
+ when(this.local.scanRange(any(), any())).thenAnswer(inv -> {
+ List kvs = new ArrayList<>();
+ this.applied.subMap(inv.getArgument(0), inv.getArgument(1))
+ .forEach((key, value) -> kvs.add(new KV(key, value)));
+ return kvs;
+ });
+ doAnswer(inv -> this.applied.put(inv.getArgument(0), inv.getArgument(1)))
+ .when(this.local).put(any(), any());
+ when(this.engine.waitReadIndexInTerm()).thenAnswer(inv -> this.term.get());
+ doAnswer(inv -> {
+ Pause pause = this.beforeFirstWrite;
+ this.beforeFirstWrite = null;
+ if (pause != null) {
+ pause.run();
+ }
+ Task task = inv.getArgument(0);
+ long expected = task.getExpectedTerm();
+ if (expected != -1L && expected != this.term.get()) {
+ this.rejectedTerms.add(expected);
+ task.getDone().run(new Status(RaftError.EPERM,
+ "expected_term=%d doesn't match " +
+ "current_term=%d", expected,
+ this.term.get()));
+ return null;
+ }
+ this.acceptedTerms.add(expected);
+ this.raftStore.invoke(KVOperation.fromByteArray(task.getData().array()),
+ null);
+ task.getDone().run(Status.OK());
+ return null;
+ }).when(this.engine).addTask(any());
+ }
+
+ private IdMetaStore allocator() {
+ Whitebox.setInternalState(MetadataFactory.class, "store", this.raftStore);
+ return new IdMetaStore(new PDConfig());
+ }
+
+ /**
+ * Runs {@code then} where the first request has read and is about to write. The
+ * lock of an id key is reentrant, so {@code then} can allocate as another leader.
+ */
+ private void pauseBeforeFirstWrite(Pause then) {
+ this.beforeFirstWrite = then;
+ }
+ }
+
+ private interface Pause {
+
+ void run() throws PDException;
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/HgKVStoreImplTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/HgKVStoreImplTest.java
index 5e77b6a829..36ef499038 100644
--- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/HgKVStoreImplTest.java
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/HgKVStoreImplTest.java
@@ -24,6 +24,8 @@
import org.apache.commons.io.FileUtils;
import org.apache.hugegraph.pd.common.PDException;
import org.apache.hugegraph.pd.config.PDConfig;
+import org.apache.hugegraph.pd.grpc.Metapb;
+import org.apache.hugegraph.pd.meta.MetadataKeyHelper;
import org.apache.hugegraph.pd.store.HgKVStore;
import org.apache.hugegraph.pd.store.HgKVStoreImpl;
import org.junit.Assert;
@@ -71,6 +73,29 @@ public void Test() throws PDException {
kvStore.close();
}
+ @Test
+ public void testScanLogRangeExcludesEnd() throws PDException {
+ long timestamp = 1700000000000L;
+ Metapb.LogRecord record = Metapb.LogRecord.newBuilder()
+ .setAction("range-boundary")
+ .setTimestamp(timestamp)
+ .build();
+ byte[] key = MetadataKeyHelper.getLogKey(record);
+ byte[] start = MetadataKeyHelper.getLogKeyPrefix(record.getAction(), 0L);
+ byte[] end = MetadataKeyHelper.getLogKeyPrefix(record.getAction(), timestamp + 1L);
+ HgKVStore kvStore = new HgKVStoreImpl();
+ kvStore.init(pdConfig);
+ try {
+ kvStore.put(key, record.toByteArray());
+
+ Assert.assertEquals(0, kvStore.scanRange(start, key).size());
+ Assert.assertEquals(1, kvStore.scanRange(key, end).size());
+ Assert.assertEquals(1, kvStore.scanRange(start, end).size());
+ } finally {
+ kvStore.close();
+ }
+ }
+
@Test
public void TestSnapshot() throws PDException {
HgKVStore kvStore = new HgKVStoreImpl();
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java
new file mode 100644
index 0000000000..eb74e5c1f5
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java
@@ -0,0 +1,246 @@
+/*
+ * 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.pd.core.store;
+
+import java.lang.reflect.Constructor;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hugegraph.pd.common.PDException;
+import org.apache.hugegraph.pd.config.PDConfig;
+import org.apache.hugegraph.pd.grpc.Pdpb;
+import org.apache.hugegraph.pd.raft.KVOperation;
+import org.apache.hugegraph.pd.raft.RaftEngine;
+import org.apache.hugegraph.pd.raft.RaftStateMachine;
+import org.apache.hugegraph.pd.store.HgKVStore;
+import org.apache.hugegraph.pd.store.KV;
+import org.apache.hugegraph.pd.store.RaftKVStore;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import com.alipay.sofa.jraft.Iterator;
+import com.alipay.sofa.jraft.Status;
+import com.alipay.sofa.jraft.entity.Task;
+import com.alipay.sofa.jraft.error.RaftError;
+
+/**
+ * Covers RaftKVStore as both sides of a raft round trip: writes become serialized raft tasks,
+ * and the decoded operations are dispatched back onto the local store by invoke().
+ */
+public class RaftKVStoreTest {
+
+ private static final byte[] KEY = "key".getBytes();
+ private static final byte[] VALUE = "value".getBytes();
+
+ private HgKVStore store;
+ private RaftEngine engine;
+ private RaftKVStore raftStore;
+
+ @Before
+ public void setUp() {
+ this.store = Mockito.mock(HgKVStore.class);
+ this.engine = Mockito.mock(RaftEngine.class);
+ this.raftStore = new RaftKVStore(this.engine, this.store);
+ }
+
+ /**
+ * Makes the mocked engine behave like a single-node leader: the task payload is decoded
+ * exactly as a follower would decode the log entry, applied, then completed with status.
+ */
+ private void applyTasksAs(Status status) {
+ Mockito.doAnswer(invocation -> {
+ Task task = invocation.getArgument(0);
+ if (status.isOk()) {
+ ByteBuffer data = task.getData();
+ byte[] bytes = new byte[data.remaining()];
+ data.get(bytes);
+ this.raftStore.invoke(KVOperation.fromByteArray(bytes), null);
+ }
+ task.getDone().run(status);
+ return null;
+ }).when(this.engine).addTask(Mockito.any(Task.class));
+ }
+
+ @Test
+ public void testInitRegistersAsRaftTaskHandler() {
+ PDConfig config = new PDConfig();
+
+ this.raftStore.init(config);
+
+ Mockito.verify(this.store).init(config);
+ Mockito.verify(this.engine).addTaskHandler(this.raftStore);
+ }
+
+ @Test
+ public void testReadsAreServedLocallyWithoutRaft() throws PDException {
+ List kvs = Collections.singletonList(new KV(KEY, VALUE));
+ Mockito.when(this.store.get(KEY)).thenReturn(VALUE);
+ Mockito.when(this.store.getWithTTL(KEY)).thenReturn(VALUE);
+ Mockito.when(this.store.getListWithTTL(KEY)).thenReturn(Collections.singletonList(VALUE));
+ Mockito.when(this.store.scanPrefix(KEY)).thenReturn(kvs);
+ Mockito.when(this.store.scanRange(KEY, VALUE)).thenReturn(kvs);
+
+ Assert.assertArrayEquals(VALUE, this.raftStore.get(KEY));
+ Assert.assertArrayEquals(VALUE, this.raftStore.getWithTTL(KEY));
+ Assert.assertEquals(1, this.raftStore.getListWithTTL(KEY).size());
+ Assert.assertSame(kvs, this.raftStore.scanPrefix(KEY));
+ Assert.assertSame(kvs, this.raftStore.scanRange(KEY, VALUE));
+
+ Mockito.verify(this.engine, Mockito.never()).addTask(Mockito.any());
+ }
+
+ @Test
+ public void testWritesReachStoreOnlyThroughRaft() throws PDException {
+ applyTasksAs(Status.OK());
+
+ this.raftStore.put(KEY, VALUE);
+ this.raftStore.putWithTTL(KEY, VALUE, 60L);
+ this.raftStore.putWithTTL(KEY, VALUE, 5L, TimeUnit.MINUTES);
+ this.raftStore.remove(KEY);
+ this.raftStore.removeByPrefix(KEY);
+ this.raftStore.removeWithTTL(KEY);
+ this.raftStore.clear();
+
+ Mockito.verify(this.engine, Mockito.times(7)).addTask(Mockito.any(Task.class));
+ Mockito.verify(this.store).put(KEY, VALUE);
+ Mockito.verify(this.store).putWithTTL(KEY, VALUE, 60L);
+ Mockito.verify(this.store).putWithTTL(KEY, VALUE, 5L, TimeUnit.MINUTES);
+ Mockito.verify(this.store).remove(KEY);
+ Mockito.verify(this.store).removeByPrefix(KEY);
+ Mockito.verify(this.store).removeWithTTL(KEY);
+ Mockito.verify(this.store).clear();
+ Mockito.verifyNoMoreInteractions(this.store);
+ }
+
+ @Test
+ public void testWriteOnNonLeaderIsRejectedAndNotApplied() throws Exception {
+ // A RaftEngine that has not joined a group is never leader
+ RaftKVStore notLeader = new RaftKVStore(newEngine(), this.store);
+
+ PDException e = Assert.assertThrows(PDException.class, () -> notLeader.put(KEY, VALUE));
+
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("Error code = " +
+ Pdpb.ErrorType.NOT_LEADER_VALUE));
+ Mockito.verifyNoInteractions(this.store);
+ }
+
+ @Test
+ public void testFailedRaftStatusFailsEveryWrite() {
+ applyTasksAs(new Status(RaftError.EINTERNAL, "injected raft failure"));
+
+ assertWriteFails(() -> this.raftStore.put(KEY, VALUE));
+ assertWriteFails(() -> this.raftStore.putWithTTL(KEY, VALUE, 1L));
+ assertWriteFails(() -> this.raftStore.putWithTTL(KEY, VALUE, 1L, TimeUnit.SECONDS));
+ assertWriteFails(() -> this.raftStore.remove(KEY));
+ assertWriteFails(() -> this.raftStore.removeByPrefix(KEY));
+ assertWriteFails(() -> this.raftStore.removeWithTTL(KEY));
+ assertWriteFails(() -> this.raftStore.clear());
+ Mockito.verifyNoInteractions(this.store);
+ }
+
+ @Test
+ public void testEngineExceptionFailsWrite() {
+ Mockito.doThrow(new IllegalStateException("engine closed"))
+ .when(this.engine).addTask(Mockito.any(Task.class));
+
+ PDException e = Assert.assertThrows(PDException.class,
+ () -> this.raftStore.put(KEY, VALUE));
+ Assert.assertTrue(e.getMessage(), e.getMessage().contains("engine closed"));
+ Mockito.verifyNoInteractions(this.store);
+ }
+
+ @Test
+ public void testInvokeDispatchesSnapshotPathFromAttach() throws PDException {
+ this.raftStore.invoke(KVOperation.createSaveSnapshot("/snap/save"), null);
+ this.raftStore.invoke(KVOperation.createLoadSnapshot("/snap/load"), null);
+
+ Mockito.verify(this.store).saveSnapshot("/snap/save");
+ Mockito.verify(this.store).loadSnapshot("/snap/load");
+ }
+
+ @Test
+ public void testInvokeIgnoresGetAndUnknownOperations() throws PDException {
+ Assert.assertFalse(this.raftStore.invoke(KVOperation.createGet(KEY), null));
+ Assert.assertFalse(this.raftStore.invoke(new KVOperation(KEY, VALUE, null, (byte) 0x7F),
+ null));
+
+ Mockito.verifyNoInteractions(this.store);
+ }
+
+ @Test
+ public void testFollowerReplayAppliesTtlWithUnit() throws Exception {
+ RaftStateMachine stateMachine = new RaftStateMachine();
+ stateMachine.addTaskHandler(this.raftStore);
+ byte[] entry = KVOperation.createPutWithTTL(KEY, VALUE, 3L, TimeUnit.HOURS)
+ .toByteArray();
+ Iterator iter = Mockito.mock(Iterator.class);
+ Mockito.when(iter.hasNext()).thenReturn(true, false);
+ // A follower has no closure for the entry, so it must decode the log data
+ Mockito.when(iter.done()).thenReturn(null);
+ Mockito.when(iter.getData()).thenReturn(ByteBuffer.wrap(entry));
+
+ stateMachine.onApply(iter);
+
+ Mockito.verify(this.store).putWithTTL(KEY, VALUE, 3L, TimeUnit.HOURS);
+ Mockito.verify(iter).next();
+ Mockito.verify(iter, Mockito.never()).setErrorAndRollback(Mockito.anyLong(),
+ Mockito.any());
+ }
+
+ @Test
+ public void testFollowerReplayStopsOnStoreFailure() throws Exception {
+ RaftStateMachine stateMachine = new RaftStateMachine();
+ stateMachine.addTaskHandler(this.raftStore);
+ Mockito.doThrow(new PDException(Pdpb.ErrorType.ROCKSDB_WRITE_ERROR_VALUE, "disk full"))
+ .when(this.store).put(KEY, VALUE);
+ Iterator iter = Mockito.mock(Iterator.class);
+ Mockito.when(iter.hasNext()).thenReturn(true, false);
+ Mockito.when(iter.getData())
+ .thenReturn(ByteBuffer.wrap(KVOperation.createPut(KEY, VALUE).toByteArray()));
+
+ stateMachine.onApply(iter);
+
+ Mockito.verify(iter).setErrorAndRollback(Mockito.eq(1L), Mockito.argThat(
+ status -> status.getRaftError() == RaftError.ESTATEMACHINE &&
+ "disk full".equals(status.getErrorMsg())));
+ Mockito.verify(iter, Mockito.never()).next();
+ }
+
+ private interface Write {
+
+ void run() throws PDException;
+ }
+
+ private static void assertWriteFails(Write write) {
+ PDException e = Assert.assertThrows(PDException.class, write::run);
+ Assert.assertEquals(Pdpb.ErrorType.UNKNOWN_VALUE, e.getErrorCode());
+ Assert.assertTrue(e.getMessage(),
+ e.getMessage().contains("Error code = " + Pdpb.ErrorType.UNKNOWN_VALUE));
+ }
+
+ private static RaftEngine newEngine() throws Exception {
+ Constructor constructor = RaftEngine.class.getDeclaredConstructor();
+ constructor.setAccessible(true);
+ return constructor.newInstance();
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/KVOperationTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/KVOperationTest.java
new file mode 100644
index 0000000000..8556a969b0
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/KVOperationTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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.pd.raft;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.caucho.hessian.io.Hessian2Output;
+
+/**
+ * Pins the wire format that a follower replays from the raft log: the op byte followed by
+ * Hessian-encoded key, value and arg, decoded through the class whitelist.
+ */
+public class KVOperationTest {
+
+ private static final byte[] KEY = {1, 2, 3};
+ private static final byte[] VALUE = {4, 5, 6, 7};
+
+ private static KVOperation roundTrip(KVOperation op) throws IOException {
+ byte[] bytes = op.toByteArray();
+ Assert.assertEquals(op.getOp(), bytes[0]);
+ return KVOperation.fromByteArray(bytes);
+ }
+
+ @Test
+ public void testPutRoundTrip() throws IOException {
+ KVOperation decoded = roundTrip(KVOperation.createPut(KEY, VALUE));
+
+ Assert.assertEquals(KVOperation.PUT, decoded.getOp());
+ Assert.assertArrayEquals(KEY, decoded.getKey());
+ Assert.assertArrayEquals(VALUE, decoded.getValue());
+ Assert.assertNull(decoded.getArg());
+ }
+
+ @Test
+ public void testGetRoundTripKeepsEmptyValue() throws IOException {
+ KVOperation decoded = roundTrip(KVOperation.createGet(KEY));
+
+ Assert.assertEquals(KVOperation.GET, decoded.getOp());
+ Assert.assertArrayEquals(KEY, decoded.getKey());
+ Assert.assertArrayEquals(new byte[0], decoded.getValue());
+ }
+
+ @Test
+ public void testRemoveOperationsCarryKeyAsValue() throws IOException {
+ KVOperation[] ops = {
+ KVOperation.createRemove(KEY),
+ KVOperation.createRemoveByPrefix(KEY),
+ KVOperation.createRemoveWithTTL(KEY)
+ };
+ byte[] expectedOps = {KVOperation.REMOVE, KVOperation.REMOVE_BY_PREFIX,
+ KVOperation.REMOVE_WITH_TTL};
+ for (int i = 0; i < ops.length; i++) {
+ KVOperation decoded = roundTrip(ops[i]);
+ Assert.assertEquals(expectedOps[i], decoded.getOp());
+ Assert.assertArrayEquals(KEY, decoded.getKey());
+ Assert.assertArrayEquals(KEY, decoded.getValue());
+ }
+ }
+
+ @Test
+ public void testPutWithTtlRoundTripKeepsLongArg() throws IOException {
+ long ttl = Integer.MAX_VALUE + 10L;
+ KVOperation decoded = roundTrip(KVOperation.createPutWithTTL(KEY, VALUE, ttl));
+
+ Assert.assertEquals(KVOperation.PUT_WITH_TTL, decoded.getOp());
+ Assert.assertArrayEquals(VALUE, decoded.getValue());
+ // RaftKVStore.invoke unboxes the arg with a (long) cast, so it must stay a Long
+ Assert.assertEquals(Long.valueOf(ttl), decoded.getArg());
+ }
+
+ @Test
+ public void testPutWithSmallTtlStillDecodesAsLong() throws IOException {
+ KVOperation decoded = roundTrip(KVOperation.createPutWithTTL(KEY, VALUE, 1L));
+
+ Assert.assertTrue(decoded.getArg() instanceof Long);
+ Assert.assertEquals(1L, (long) decoded.getArg());
+ }
+
+ @Test
+ public void testPutWithTtlUnitRoundTripKeepsTtlAndUnit() throws IOException {
+ KVOperation decoded = roundTrip(KVOperation.createPutWithTTL(KEY, VALUE, 30L,
+ TimeUnit.SECONDS));
+
+ Assert.assertEquals(KVOperation.PUT_WITH_TTL_UNIT, decoded.getOp());
+ Object[] arg = (Object[]) decoded.getArg();
+ Assert.assertEquals(2, arg.length);
+ Assert.assertEquals(30L, (long) arg[0]);
+ Assert.assertSame(TimeUnit.SECONDS, arg[1]);
+ }
+
+ @Test
+ public void testClearRoundTripHasNullKeyAndValue() throws IOException {
+ KVOperation decoded = roundTrip(KVOperation.createClear());
+
+ Assert.assertEquals(KVOperation.CLEAR, decoded.getOp());
+ Assert.assertNull(decoded.getKey());
+ Assert.assertNull(decoded.getValue());
+ }
+
+ @Test
+ public void testSnapshotPathTravelsOnlyInAttach() throws IOException {
+ KVOperation save = KVOperation.createSaveSnapshot("/data/snapshot");
+ Assert.assertEquals("/data/snapshot", save.getAttach());
+
+ // Snapshot operations are created locally by the state machine and never replicated:
+ // the path is held in the transient attach field and is not part of the encoding.
+ KVOperation decoded = roundTrip(save);
+ Assert.assertEquals(KVOperation.SAVE_SNAPSHOT, decoded.getOp());
+ Assert.assertNull(decoded.getAttach());
+ Assert.assertNull(decoded.getArg());
+
+ Assert.assertEquals(KVOperation.LOAD_SNAPSHOT,
+ KVOperation.createLoadSnapshot("/data/snapshot").getOp());
+ }
+
+ @Test
+ public void testFactoriesRejectNullArguments() {
+ assertNpe(() -> KVOperation.createPut(null, VALUE));
+ assertNpe(() -> KVOperation.createPut(KEY, null));
+ assertNpe(() -> KVOperation.createGet(null));
+ assertNpe(() -> KVOperation.createPutWithTTL(null, VALUE, 1L));
+ assertNpe(() -> KVOperation.createPutWithTTL(KEY, null, 1L, TimeUnit.SECONDS));
+ assertNpe(() -> KVOperation.createRemove(null));
+ assertNpe(() -> KVOperation.createRemoveByPrefix(null));
+ assertNpe(() -> KVOperation.createRemoveWithTTL(null));
+ assertNpe(() -> KVOperation.createSaveSnapshot(null));
+ assertNpe(() -> KVOperation.createLoadSnapshot(null));
+ }
+
+ @Test
+ public void testTruncatedEntryFailsToDecode() throws IOException {
+ byte[] bytes = KVOperation.createPut(KEY, VALUE).toByteArray();
+ byte[] truncated = Arrays.copyOf(bytes, bytes.length - 2);
+
+ Assert.assertThrows(IOException.class, () -> KVOperation.fromByteArray(truncated));
+ }
+
+ @Test
+ public void testDecodeNeverInstantiatesClassOutsideWhitelist() throws IOException {
+ byte[] bytes = encode(KVOperation.PUT, KEY, VALUE, new NotWhitelisted("payload"));
+
+ KVOperation decoded = KVOperation.fromByteArray(bytes);
+
+ // The whitelist must keep the class from being instantiated
+ Assert.assertFalse(decoded.getArg() instanceof NotWhitelisted);
+ Assert.assertArrayEquals(KEY, decoded.getKey());
+ }
+
+ @Test
+ public void testDecodeAllowsWhitelistedCollections() throws IOException {
+ byte[] bytes = encode(KVOperation.PUT, KEY, VALUE,
+ new ArrayList<>(Arrays.asList("a", "b")));
+
+ KVOperation decoded = KVOperation.fromByteArray(bytes);
+ Assert.assertEquals(Arrays.asList("a", "b"), decoded.getArg());
+ }
+
+ private static byte[] encode(byte op, byte[] key, byte[] value, Object arg)
+ throws IOException {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ bos.write(op);
+ Hessian2Output output = new Hessian2Output(bos);
+ output.writeObject(key);
+ output.writeObject(value);
+ output.writeObject(arg);
+ output.flush();
+ return bos.toByteArray();
+ }
+
+ private static void assertNpe(Runnable action) {
+ Assert.assertThrows(NullPointerException.class, action::run);
+ }
+
+ public static class NotWhitelisted implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+ public String data;
+
+ public NotWhitelisted(String data) {
+ this.data = data;
+ }
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/PeerUtilTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/PeerUtilTest.java
new file mode 100644
index 0000000000..067f8d523e
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/PeerUtilTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.pd.raft;
+
+import java.util.List;
+
+import org.apache.hugegraph.pd.common.KVPair;
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.alipay.sofa.jraft.entity.PeerId;
+
+public class PeerUtilTest {
+
+ @Test
+ public void testIsPeerEqualsComparesOnlyIpAndPort() {
+ PeerId peer = new PeerId("10.0.0.1", 8610);
+ // Same endpoint with a different replica index and priority still names the same peer
+ PeerId sameEndpoint = new PeerId("10.0.0.1", 8610, 3, 100);
+
+ Assert.assertTrue(PeerUtil.isPeerEquals(peer, sameEndpoint));
+ Assert.assertFalse(PeerUtil.isPeerEquals(peer, new PeerId("10.0.0.1", 8611)));
+ Assert.assertFalse(PeerUtil.isPeerEquals(peer, new PeerId("10.0.0.2", 8610)));
+ }
+
+ @Test
+ public void testIsPeerEqualsHandlesNull() {
+ PeerId peer = new PeerId("10.0.0.1", 8610);
+
+ Assert.assertTrue(PeerUtil.isPeerEquals(null, null));
+ Assert.assertFalse(PeerUtil.isPeerEquals(peer, null));
+ Assert.assertFalse(PeerUtil.isPeerEquals(null, peer));
+ }
+
+ @Test
+ public void testParseConfigAssignsRolesFromSuffix() {
+ List> peers = PeerUtil.parseConfig(
+ "10.0.0.1:8610/leader,10.0.0.2:8610/learner,10.0.0.3:8610/follower," +
+ "10.0.0.4:8610");
+
+ Assert.assertEquals(4, peers.size());
+ assertPeer(peers.get(0), "leader", "10.0.0.1", 8610);
+ assertPeer(peers.get(1), "learner", "10.0.0.2", 8610);
+ assertPeer(peers.get(2), "follower", "10.0.0.3", 8610);
+ // A peer without a role suffix defaults to follower
+ assertPeer(peers.get(3), "follower", "10.0.0.4", 8610);
+ }
+
+ @Test
+ public void testParseConfigKeepsReplicaIndex() {
+ List> peers = PeerUtil.parseConfig("10.0.0.1:8610:2/learner");
+
+ Assert.assertEquals(1, peers.size());
+ Assert.assertEquals("learner", peers.get(0).getKey());
+ Assert.assertEquals(2, peers.get(0).getValue().getIdx());
+ }
+
+ @Test
+ public void testParseConfigOfEmptyInputIsEmpty() {
+ Assert.assertTrue(PeerUtil.parseConfig(null).isEmpty());
+ Assert.assertTrue(PeerUtil.parseConfig("").isEmpty());
+ }
+
+ @Test
+ public void testParseConfigRejectsMalformedPeer() {
+ Assert.assertThrows(IllegalArgumentException.class,
+ () -> PeerUtil.parseConfig("10.0.0.1:8610,not-a-peer"));
+ Assert.assertThrows(IllegalArgumentException.class,
+ () -> PeerUtil.parseConfig("10.0.0.1:port/leader"));
+ // An unknown role is not stripped, so the peer string itself becomes invalid
+ Assert.assertThrows(IllegalArgumentException.class,
+ () -> PeerUtil.parseConfig("10.0.0.1:8610/witness"));
+ }
+
+ private static void assertPeer(KVPair pair, String role, String ip,
+ int port) {
+ Assert.assertEquals(role, pair.getKey());
+ Assert.assertEquals(ip, pair.getValue().getIp());
+ Assert.assertEquals(port, pair.getValue().getPort());
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadIndexTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadIndexTest.java
new file mode 100644
index 0000000000..85d250bc45
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadIndexTest.java
@@ -0,0 +1,395 @@
+/*
+ * 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.pd.raft;
+
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.hugegraph.pd.common.PDException;
+import org.apache.hugegraph.pd.config.PDConfig;
+import org.apache.hugegraph.pd.grpc.Pdpb;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.alipay.sofa.jraft.Node;
+import com.alipay.sofa.jraft.Status;
+import com.alipay.sofa.jraft.closure.ReadIndexClosure;
+import com.alipay.sofa.jraft.error.RaftError;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Covers {@link RaftEngine#waitReadIndex()}, the barrier a leader runs before a local
+ * read-then-write such as the id counter. The raft node is a mock that answers each
+ * ReadIndex call the way jraft does: it sets the result and runs the closure.
+ */
+public class RaftEngineReadIndexTest {
+
+ // Queued in place of a status: the closure is never run, as when no quorum answers
+ private static final Status NO_ANSWER = new Status(RaftError.UNKNOWN, "no answer");
+
+ private Node originalRaftNode;
+ private PDConfig.Raft originalConfig;
+ private long originalLeaderTerm;
+ // The term jraft announced this node as the leader of, -1 while it is not the leader
+ private AtomicLong leaderTerm;
+ private Node mockNode;
+ // One status per readIndex call, OK once the queue is empty
+ private final Deque answers = new ConcurrentLinkedDeque<>();
+
+ @Before
+ public void setUp() {
+ RaftEngine engine = RaftEngine.getInstance();
+ this.originalRaftNode = engine.getRaftNode();
+ this.mockNode = mock(Node.class);
+ doAnswer(invocation -> {
+ ReadIndexClosure closure = invocation.getArgument(1);
+ Status status = this.answers.isEmpty() ? Status.OK() : this.answers.poll();
+ if (status != NO_ANSWER) {
+ closure.setResult(status.isOk() ? 42L : ReadIndexClosure.INVALID_LOG_INDEX,
+ invocation.getArgument(0));
+ closure.run(status);
+ }
+ return null;
+ }).when(this.mockNode).readIndex(any(byte[].class), any(ReadIndexClosure.class));
+ Whitebox.setInternalState(engine, "raftNode", this.mockNode);
+
+ this.originalConfig = engine.getConfig();
+ PDConfig.Raft config = new PDConfig().new Raft();
+ config.setRpcTimeout(1000);
+ Whitebox.setInternalState(engine, "config", config);
+ RaftStateMachine machine = Whitebox.getInternalState(engine, "stateMachine");
+ this.leaderTerm = Whitebox.getInternalState(machine, "leaderTerm");
+ this.originalLeaderTerm = this.leaderTerm.get();
+ }
+
+ @After
+ public void tearDown() {
+ RaftEngine engine = RaftEngine.getInstance();
+ Whitebox.setInternalState(engine, "raftNode", this.originalRaftNode);
+ Whitebox.setInternalState(engine, "config", this.originalConfig);
+ this.leaderTerm.set(this.originalLeaderTerm);
+ }
+
+ @Test
+ public void testReturnsOnceTheReadIndexIsApplied() throws PDException {
+ RaftEngine.getInstance().waitReadIndex(1000L);
+
+ verify(this.mockNode, times(1)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testRetriesWhileTheNewLeaderHasNoCommitInItsTerm() throws PDException {
+ this.answers.addAll(Arrays.asList(new Status(RaftError.EAGAIN, "no commit yet"),
+ new Status(RaftError.EBUSY, "transferring"),
+ Status.OK()));
+
+ RaftEngine.getInstance().waitReadIndex(1000L);
+
+ verify(this.mockNode, times(3)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testRetriesPastTheFirstHundredMillisecondsUntilTheLeaderCommits()
+ throws PDException {
+ // 15 answers 20 ms apart: the leader needs about 300 ms to commit in its term
+ for (int i = 0; i < 15; i++) {
+ this.answers.add(new Status(RaftError.EAGAIN, "no commit yet"));
+ }
+
+ RaftEngine.getInstance().waitReadIndex(5000L);
+
+ verify(this.mockNode, times(16)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testRetriesStopAtTheDeadline() throws InterruptedException {
+ for (int i = 0; i < 1000; i++) {
+ this.answers.add(new Status(RaftError.EAGAIN, "no commit yet"));
+ }
+
+ long start = System.nanoTime();
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(200L);
+ });
+ Assert.assertTrue(System.nanoTime() - start < 1_000_000_000L);
+ Assert.assertEquals(Pdpb.ErrorType.UNKNOWN_VALUE, e.getErrorCode());
+
+ // No retry is left running once the wait has given up
+ int calls = 1000 - this.answers.size();
+ Thread.sleep(100L);
+ Assert.assertEquals(calls, 1000 - this.answers.size());
+ }
+
+ @Test
+ public void testBusyNodeIsRetriedLessAndLessOften() throws InterruptedException {
+ // EBUSY is also jraft's answer while its read index queue is full. A retry every
+ // 20 ms would be about 25 calls in 500 ms; doubled delays give calls at 0, 20, 60,
+ // 140 and 300 ms.
+ for (int i = 0; i < 1000; i++) {
+ this.answers.add(new Status(RaftError.EBUSY, "read index queue is full"));
+ }
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(500L);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.UNKNOWN_VALUE, e.getErrorCode());
+ int calls = 1000 - this.answers.size();
+ Assert.assertTrue("ReadIndex calls: " + calls, calls >= 3 && calls <= 6);
+ Thread.sleep(100L);
+ Assert.assertEquals(calls, 1000 - this.answers.size());
+ }
+
+ @Test
+ public void testBackoffStartsOverAfterAnAnswerThatIsNotBusy() throws PDException {
+ // 20 + 40 + 80 + 160 ms for four EBUSY answers, 20 ms for EAGAIN, then 20 ms again
+ // for the next EBUSY: 340 ms. It would wait 320 ms, 640 ms in all, had it kept
+ // the count
+ for (int i = 0; i < 4; i++) {
+ this.answers.add(new Status(RaftError.EBUSY, "transferring"));
+ }
+ this.answers.addAll(Arrays.asList(new Status(RaftError.EAGAIN, "no commit yet"),
+ new Status(RaftError.EBUSY, "transferring")));
+
+ long start = System.nanoTime();
+ RaftEngine.getInstance().waitReadIndex(5000L);
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+
+ Assert.assertTrue("Returned after " + elapsedMs + " ms", elapsedMs < 500L);
+ verify(this.mockNode, times(7)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testReadIndexInTermReturnsTheLeaderTerm() throws PDException {
+ this.leaderTerm.set(5L);
+
+ Assert.assertEquals(5L, RaftEngine.getInstance().waitReadIndexInTerm());
+
+ verify(this.mockNode, times(1)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testReadIndexInTermKeepsTheTermTakenBeforeTheWait() throws PDException {
+ // The node loses the leadership of term 5 and wins term 7 during the wait. The
+ // caller must get 5: its write is then rejected, as the wait began in another term
+ this.leaderTerm.set(5L);
+ doAnswer(invocation -> {
+ this.leaderTerm.set(7L);
+ ReadIndexClosure closure = invocation.getArgument(1);
+ closure.setResult(42L, invocation.getArgument(0));
+ closure.run(Status.OK());
+ return null;
+ }).when(this.mockNode).readIndex(any(byte[].class), any(ReadIndexClosure.class));
+
+ Assert.assertEquals(5L, RaftEngine.getInstance().waitReadIndexInTerm());
+ }
+
+ @Test
+ public void testReadIndexInTermWaitsAgainOnceANewLeaderIsAnnounced() throws PDException {
+ // Elected, but not announced before the first entry of the term is applied: the
+ // first wait ends after that, the second one runs inside the announced term
+ this.leaderTerm.set(-1L);
+ doAnswer(invocation -> {
+ this.leaderTerm.set(4L);
+ ReadIndexClosure closure = invocation.getArgument(1);
+ closure.setResult(42L, invocation.getArgument(0));
+ closure.run(Status.OK());
+ return null;
+ }).when(this.mockNode).readIndex(any(byte[].class), any(ReadIndexClosure.class));
+
+ Assert.assertEquals(4L, RaftEngine.getInstance().waitReadIndexInTerm());
+
+ verify(this.mockNode, times(2)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testReadIndexInTermFailsAsNotLeaderOnAFollower() {
+ // A follower's ReadIndex succeeds through the leader, but it has no term to write in
+ this.leaderTerm.set(-1L);
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndexInTerm();
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ verify(this.mockNode, times(1)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testNodeThatLostLeadershipFailsAsNotLeader() {
+ this.answers.add(new Status(RaftError.EPERM, "not leader"));
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(1000L);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ verify(this.mockNode, times(1)).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testWaitIsBoundedWhenNoQuorumAnswers() {
+ this.answers.add(NO_ANSWER);
+
+ long start = System.nanoTime();
+ Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(100L);
+ });
+ Assert.assertTrue(System.nanoTime() - start < 1_000_000_000L);
+ }
+
+ @Test
+ public void testEveryTimeoutReportsHowLongTheWaitWas() {
+ // No time left when the ReadIndex would be sent
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(0L);
+ });
+ Assert.assertEquals("Raft read index timed out after 0 ms", e.getMessage());
+ verify(this.mockNode, never()).readIndex(any(byte[].class),
+ any(ReadIndexClosure.class));
+
+ // No answer: the closure's own timer runs it with ETIMEDOUT about when the wait ends
+ this.answers.add(NO_ANSWER);
+ e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(100L);
+ });
+ Assert.assertEquals("Raft read index timed out after 100 ms", e.getMessage());
+ }
+
+ @Test
+ public void testOneReadIndexMayWaitPastTheJraftDefaultTimeout() {
+ // No answer at all: the wait lasts until its own deadline. A closure created without
+ // a timeout passes this too, since its ETIMEDOUT at 2 s is left to the waiter; the
+ // next test tells the two apart
+ this.answers.add(NO_ANSWER);
+
+ long start = System.nanoTime();
+ Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(2600L);
+ });
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+ Assert.assertTrue("Failed after " + elapsedMs + " ms", elapsedMs >= 2400L);
+ }
+
+ @Test
+ public void testAnswerAfterTheJraftDefaultTimeoutStillCompletesTheWait()
+ throws PDException {
+ // The leader answers at 2.5 s, after the 2 s default timer of a ReadIndexClosure
+ // created without a timeout and well before the 5 s deadline of this wait. A closure
+ // that timed out drops the answer, and the wait would fail at its deadline.
+ ScheduledExecutorService leader = Executors.newSingleThreadScheduledExecutor();
+ try {
+ doAnswer(invocation -> {
+ ReadIndexClosure closure = invocation.getArgument(1);
+ byte[] context = invocation.getArgument(0);
+ leader.schedule(() -> {
+ closure.setResult(42L, context);
+ closure.run(Status.OK());
+ }, 2500L, TimeUnit.MILLISECONDS);
+ return null;
+ }).when(this.mockNode).readIndex(any(byte[].class), any(ReadIndexClosure.class));
+
+ long start = System.nanoTime();
+ RaftEngine.getInstance().waitReadIndex(5000L);
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+ Assert.assertTrue("Returned after " + elapsedMs + " ms", elapsedMs >= 2400L);
+ } finally {
+ leader.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testNodeShuttingDownFailsAsNotLeader() {
+ doThrow(new IllegalStateException("Node is shutting down"))
+ .when(this.mockNode).readIndex(any(byte[].class), any(ReadIndexClosure.class));
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(1000L);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ }
+
+ @Test
+ public void testShutdownClosureThatRunsFirstFailsAsNotLeader() {
+ // jraft runs the closure with ENODESHUTDOWN on another thread and then throws;
+ // here the closure always completes the wait before the exception is caught
+ this.answerShutdown(RaftError.ENODESHUTDOWN, "Node is shutting down");
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(1000L);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ }
+
+ @Test
+ public void testStoppedReadServiceClosureThatRunsFirstFailsAsNotLeader() {
+ // The read service fails the closure with EHOSTDOWN before it throws
+ this.answerShutdown(RaftError.EHOSTDOWN, "Service already shutdown.");
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(1000L);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ }
+
+ private void answerShutdown(RaftError error, String message) {
+ doAnswer(invocation -> {
+ ReadIndexClosure closure = invocation.getArgument(1);
+ closure.setResult(ReadIndexClosure.INVALID_LOG_INDEX, invocation.getArgument(0));
+ closure.run(new Status(error, message));
+ throw new IllegalStateException(message);
+ }).when(this.mockNode).readIndex(any(byte[].class), any(ReadIndexClosure.class));
+ }
+
+ @Test
+ public void testMissingRaftNodeFailsAsNotLeader() {
+ Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", null);
+
+ PDException e = Assert.assertThrows(PDException.class, () -> {
+ RaftEngine.getInstance().waitReadIndex(1000L);
+ });
+
+ Assert.assertEquals(Pdpb.ErrorType.NOT_LEADER_VALUE, e.getErrorCode());
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadinessTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadinessTest.java
index 08c35287d8..0f73ca894e 100644
--- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadinessTest.java
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadinessTest.java
@@ -17,10 +17,12 @@
package org.apache.hugegraph.pd.raft;
+import java.lang.reflect.Constructor;
import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import org.apache.hugegraph.testutil.Whitebox;
-import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
@@ -50,32 +52,24 @@ public class RaftEngineReadinessTest {
private static final PeerId LEADER = new PeerId("10.0.0.1", 8610);
- private Node originalRaftNode;
- private RaftStateMachine originalStateMachine;
-
+ private RaftEngine engine;
private Node mockNode;
private RaftStateMachine stateMachine;
@Before
- public void setUp() {
- RaftEngine engine = RaftEngine.getInstance();
- originalRaftNode = engine.getRaftNode();
- originalStateMachine = Whitebox.getInternalState(engine, "stateMachine");
-
- // A fresh machine so callbacks fired here reach no listeners other tests registered
+ public void setUp() throws Exception {
+ // Other suites leave scheduled tasks reading the shared engine. Keep this mock
+ // private to the test so their isLeader() calls cannot affect interaction checks.
+ engine = newEngine();
mockNode = mock(Node.class);
- stateMachine = new RaftStateMachine();
+ stateMachine = Whitebox.getInternalState(engine, "stateMachine");
Whitebox.setInternalState(engine, "raftNode", mockNode);
- Whitebox.setInternalState(engine, "stateMachine", stateMachine);
- // The count is cached on the singleton, so clear what an earlier test published
- engine.refreshAlivePeerCount();
}
- @After
- public void tearDown() {
- RaftEngine engine = RaftEngine.getInstance();
- Whitebox.setInternalState(engine, "raftNode", originalRaftNode);
- Whitebox.setInternalState(engine, "stateMachine", originalStateMachine);
+ private static RaftEngine newEngine() throws Exception {
+ Constructor constructor = RaftEngine.class.getDeclaredConstructor();
+ constructor.setAccessible(true);
+ return constructor.newInstance();
}
private static LeaderChangeContext ctx() {
@@ -84,8 +78,8 @@ private static LeaderChangeContext ctx() {
@Test
public void testNotReadyBeforeRaftNodeStarts() {
- Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", null);
- RaftEngine engine = RaftEngine.getInstance();
+ Whitebox.setInternalState(this.engine, "raftNode", null);
+ RaftEngine engine = this.engine;
engine.refreshAlivePeerCount();
RaftEngine.RaftStatus status = engine.getRaftStatus();
@@ -98,7 +92,7 @@ public void testNotReadyBeforeRaftNodeStarts() {
@Test
public void testStartedNodeWithoutAnyCallbackIsNotReady() {
- RaftEngine.RaftStatus status = RaftEngine.getInstance().getRaftStatus();
+ RaftEngine.RaftStatus status = this.engine.getRaftStatus();
Assert.assertFalse(status.isReady());
Assert.assertEquals(State.STATE_UNINITIALIZED.name(), status.getState());
}
@@ -108,7 +102,7 @@ public void testLeaderIsReady() {
stateMachine.onLeaderStart(5);
when(mockNode.listAlivePeers()).thenReturn(Arrays.asList(LEADER, new PeerId("b", 1),
new PeerId("c", 1)));
- RaftEngine engine = RaftEngine.getInstance();
+ RaftEngine engine = this.engine;
engine.refreshAlivePeerCount();
RaftEngine.RaftStatus status = engine.getRaftStatus();
@@ -122,7 +116,7 @@ public void testLeaderIsReady() {
@Test
public void testFollowerWithLeaderIsReady() {
stateMachine.onStartFollowing(ctx());
- RaftEngine engine = RaftEngine.getInstance();
+ RaftEngine engine = this.engine;
engine.refreshAlivePeerCount();
RaftEngine.RaftStatus status = engine.getRaftStatus();
@@ -139,7 +133,7 @@ public void testFollowerLosingItsLeaderTurnsNotReady() {
// The quorum-loss shape from the issue: heartbeats stop, jraft announces the loss
stateMachine.onStartFollowing(ctx());
stateMachine.onStopFollowing(ctx());
- RaftEngine engine = RaftEngine.getInstance();
+ RaftEngine engine = this.engine;
RaftEngine.RaftStatus status = engine.getRaftStatus();
Assert.assertFalse(status.isReady());
@@ -151,7 +145,7 @@ public void testFollowerLosingItsLeaderTurnsNotReady() {
public void testLeaderSteppingDownTurnsNotReady() {
stateMachine.onLeaderStart(5);
stateMachine.onLeaderStop(Status.OK());
- RaftEngine engine = RaftEngine.getInstance();
+ RaftEngine engine = this.engine;
engine.refreshAlivePeerCount();
RaftEngine.RaftStatus status = engine.getRaftStatus();
@@ -165,14 +159,14 @@ public void testLeaderSteppingDownTurnsNotReady() {
public void testErrorAndShutdownAreNotReadyEvenAfterLeadership() {
stateMachine.onLeaderStart(5);
stateMachine.onError(mock(RaftException.class));
- Assert.assertFalse(RaftEngine.getInstance().getRaftStatus().isReady());
+ Assert.assertFalse(this.engine.getRaftStatus().isReady());
Assert.assertEquals(State.STATE_ERROR.name(),
- RaftEngine.getInstance().getRaftStatus().getState());
+ this.engine.getRaftStatus().getState());
stateMachine.onShutdown();
- Assert.assertFalse(RaftEngine.getInstance().getRaftStatus().isReady());
+ Assert.assertFalse(this.engine.getRaftStatus().isReady());
Assert.assertEquals(State.STATE_SHUTDOWN.name(),
- RaftEngine.getInstance().getRaftStatus().getState());
+ this.engine.getRaftStatus().getState());
}
@Test
@@ -182,7 +176,7 @@ public void testProbeNeverTouchesTheRaftNode() {
// getAlivePeerCount() included: it reads what the refresher thread published
stateMachine.onStartFollowing(ctx());
stateMachine.onStopFollowing(ctx());
- RaftEngine engine = RaftEngine.getInstance();
+ RaftEngine engine = this.engine;
engine.getRaftStatus();
engine.hasLeader();
@@ -195,9 +189,9 @@ public void testProbeNeverTouchesTheRaftNode() {
public void testAlivePeerCountSurvivesLeadershipLossRace() {
stateMachine.onLeaderStart(5);
when(mockNode.listAlivePeers()).thenThrow(new IllegalStateException("Not leader"));
- RaftEngine.getInstance().refreshAlivePeerCount();
+ this.engine.refreshAlivePeerCount();
- Assert.assertEquals(-1, RaftEngine.getInstance().getAlivePeerCount());
+ Assert.assertEquals(-1, this.engine.getAlivePeerCount());
}
@Test
@@ -207,8 +201,8 @@ public void testShutdownStateSurvivesTheNodeBeingDropped() {
// uninitialized, which would read as a PD that has not started yet
stateMachine.onLeaderStart(5);
stateMachine.onShutdown();
- Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", null);
- RaftEngine engine = RaftEngine.getInstance();
+ Whitebox.setInternalState(this.engine, "raftNode", null);
+ RaftEngine engine = this.engine;
RaftEngine.RaftStatus status = engine.getRaftStatus();
Assert.assertFalse(status.isReady());
@@ -217,13 +211,29 @@ public void testShutdownStateSurvivesTheNodeBeingDropped() {
Assert.assertFalse(engine.hasLeader());
}
+ @Test
+ public void testNewNodeResetAllowsLeadershipAfterShutdown() throws Exception {
+ stateMachine.onLeaderStart(5);
+ stateMachine.onShutdown();
+ stateMachine.stopLeaderCallbacks();
+ CountDownLatch notified = new CountDownLatch(1);
+ stateMachine.addStateListener(notified::countDown);
+ stateMachine.resetForNewNode();
+ Assert.assertEquals(State.STATE_UNINITIALIZED.name(), engine.getRaftStatus().getState());
+ Assert.assertFalse(stateMachine.isLeader());
+ stateMachine.onLeaderStart(7);
+ Assert.assertEquals(State.STATE_LEADER.name(), engine.getRaftStatus().getState());
+ Assert.assertTrue(stateMachine.isLeader());
+ Assert.assertTrue(notified.await(5, TimeUnit.SECONDS));
+ }
+
@Test
public void testAlivePeerCountStopsAfterALeaderErrorsOrShutsDown() {
// The terminal callbacks clear the leader term, so the refresher stops reading the
// count off a node that errored or shut down and the gauge falls back to NaN
stateMachine.onLeaderStart(5);
when(mockNode.listAlivePeers()).thenReturn(Arrays.asList(LEADER, new PeerId("b", 1)));
- RaftEngine engine = RaftEngine.getInstance();
+ RaftEngine engine = this.engine;
engine.refreshAlivePeerCount();
Assert.assertEquals(2, engine.getAlivePeerCount());
@@ -233,13 +243,13 @@ public void testAlivePeerCountStopsAfterALeaderErrorsOrShutsDown() {
stateMachine.onLeaderStart(6);
engine.refreshAlivePeerCount();
- Assert.assertEquals(2, engine.getAlivePeerCount());
+ Assert.assertEquals(-1, engine.getAlivePeerCount());
stateMachine.onShutdown();
engine.refreshAlivePeerCount();
Assert.assertEquals(-1, engine.getAlivePeerCount());
- // Twice, once per leader phase: the terminal states never reached the node
- verify(mockNode, times(2)).listAlivePeers();
+ // Only the initial leader phase reads the node; queued starts cannot recover terminal state.
+ verify(mockNode, times(1)).listAlivePeers();
}
@Test
@@ -248,7 +258,7 @@ public void testAlivePeerCountOnALeaderAlsoSkipsTheNode() {
// value: listAlivePeers takes the node read lock before it checks for leadership, so
// a scrape that called it would wait out an election that holds the write lock
stateMachine.onLeaderStart(5);
- RaftEngine engine = RaftEngine.getInstance();
+ RaftEngine engine = this.engine;
engine.getAlivePeerCount();
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineRpcTimeoutTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineRpcTimeoutTest.java
new file mode 100644
index 0000000000..3c22da393b
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineRpcTimeoutTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.pd.raft;
+
+import org.apache.hugegraph.pd.config.PDConfig;
+import org.junit.Assert;
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Value;
+
+import com.alipay.sofa.jraft.option.NodeOptions;
+
+/**
+ * A candidate opens a connection to every peer while it holds the raft node lock, so the
+ * connect timeout bounds how long one unanswering peer stalls an election. It used to be
+ * {@code raft.rpc-timeout} (10 s), shared with the install-snapshot timeout; each now has
+ * its own option, and the install-snapshot timeout still follows rpc-timeout unless set.
+ */
+public class RaftEngineRpcTimeoutTest {
+
+ @Test
+ public void testEachRpcTimeoutComesFromItsOwnOption() {
+ PDConfig.Raft raft = new PDConfig().new Raft();
+ raft.setRpcTimeout(7000);
+ raft.setRpcConnectTimeout(900);
+ raft.setRpcInstallSnapshotTimeout(120000);
+ NodeOptions options = new NodeOptions();
+
+ RaftEngine.setRpcTimeouts(options, raft);
+
+ Assert.assertEquals(900, options.getRpcConnectTimeoutMs());
+ Assert.assertEquals(7000, options.getRpcDefaultTimeout());
+ Assert.assertEquals(120000, options.getRpcInstallSnapshotTimeout());
+ }
+
+ @Test
+ public void testDefaults() throws NoSuchFieldException {
+ // The connect timeout takes jraft's default of 1 s
+ Assert.assertEquals("${raft.rpc-connect-timeout:1000}",
+ valueOf("rpcConnectTimeout"));
+ Assert.assertEquals("${raft.rpc-install-snapshot-timeout:0}",
+ valueOf("rpcInstallSnapshotTimeout"));
+ Assert.assertEquals("${raft.rpc-timeout:10000}", valueOf("rpcTimeout"));
+
+ PDConfig.Raft raft = new PDConfig().new Raft();
+ Assert.assertEquals(1000, raft.getRpcConnectTimeout());
+ Assert.assertEquals(new NodeOptions().getRpcConnectTimeoutMs(),
+ raft.getRpcConnectTimeout());
+ }
+
+ @Test
+ public void testInstallSnapshotTimeoutFollowsRpcTimeoutWhenUnset() {
+ PDConfig.Raft raft = new PDConfig().new Raft();
+ raft.setRpcTimeout(10000);
+ NodeOptions options = new NodeOptions();
+
+ RaftEngine.setRpcTimeouts(options, raft);
+
+ Assert.assertEquals(10000, options.getRpcInstallSnapshotTimeout());
+ }
+
+ private static String valueOf(String field) throws NoSuchFieldException {
+ return PDConfig.Raft.class.getDeclaredField(field).getAnnotation(Value.class).value();
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachineSnapshotTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachineSnapshotTest.java
new file mode 100644
index 0000000000..6f06de04e0
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachineSnapshotTest.java
@@ -0,0 +1,241 @@
+/*
+ * 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.pd.raft;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.hugegraph.pd.common.PDException;
+import org.apache.hugegraph.pd.grpc.Pdpb;
+import org.apache.hugegraph.pd.service.MetadataService;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import com.alipay.sofa.jraft.Closure;
+import com.alipay.sofa.jraft.Status;
+import com.alipay.sofa.jraft.entity.LocalFileMetaOutter.LocalFileMeta;
+import com.alipay.sofa.jraft.error.RaftError;
+import com.alipay.sofa.jraft.storage.snapshot.SnapshotReader;
+import com.alipay.sofa.jraft.storage.snapshot.SnapshotWriter;
+import com.google.protobuf.Message;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * jraft calls {@code onSnapshotSave} on the state machine thread with the snapshot index
+ * set to the last applied index, and applies the next entry once it returns. The checkpoint
+ * must therefore be taken before the call returns: a checkpoint taken later on a job thread
+ * holds entries past the snapshot index, which a node installing it applies a second time.
+ *
+ * {@code onSnapshotLoad} must return false for an archive it did not install: jraft moves
+ * its applied index to the snapshot index when the load returns true.
+ */
+public class RaftStateMachineSnapshotTest {
+
+ private ThreadPoolExecutor originalJobs;
+ private ThreadPoolExecutor jobs;
+ private File snapshotPath;
+ private SnapshotWriter writer;
+
+ @Before
+ public void setUp() throws IOException {
+ this.originalJobs = MetadataService.getUninterruptibleJobs();
+ this.jobs = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<>());
+ Whitebox.setInternalState(MetadataService.class, "uninterruptibleJobs", this.jobs);
+
+ this.snapshotPath = Files.createTempDirectory("pd-snapshot-save").toFile();
+ this.writer = mock(SnapshotWriter.class);
+ when(this.writer.getPath()).thenReturn(this.snapshotPath.getAbsolutePath());
+ when(this.writer.addFile(anyString(), any(Message.class))).thenReturn(true);
+ }
+
+ @After
+ public void tearDown() throws IOException {
+ Whitebox.setInternalState(MetadataService.class, "uninterruptibleJobs",
+ this.originalJobs);
+ this.jobs.shutdownNow();
+ FileUtils.deleteDirectory(this.snapshotPath);
+ }
+
+ @Test
+ public void testCheckpointIsTakenBeforeTheNextEntryIsApplied() throws Exception {
+ AtomicLong applied = new AtomicLong(10L);
+ AtomicReference checkpointThread = new AtomicReference<>();
+ AtomicLong checkpointed = new AtomicLong(-1L);
+ RaftStateMachine machine = new RaftStateMachine();
+ machine.addTaskHandler((op, response) -> {
+ if (op.getOp() == KVOperation.SAVE_SNAPSHOT) {
+ checkpointThread.set(Thread.currentThread());
+ checkpointed.set(applied.get());
+ writeCheckpoint((String) op.getAttach(), applied.get());
+ }
+ return false;
+ });
+ RecordingClosure done = new RecordingClosure();
+
+ machine.onSnapshotSave(this.writer, done);
+ // What the state machine thread does next: apply the entry after the snapshot
+ applied.incrementAndGet();
+
+ Assert.assertSame(Thread.currentThread(), checkpointThread.get());
+ Assert.assertEquals(10L, checkpointed.get());
+ Assert.assertTrue(done.await());
+ Assert.assertEquals(1, done.statuses.size());
+ Assert.assertTrue(done.statuses.get(0).isOk());
+ Assert.assertTrue(new File(this.snapshotPath, "snapshot.zip").isFile());
+ }
+
+ @Test
+ public void testFailedCheckpointCompletesTheSnapshotOnceWithAnError() throws Exception {
+ RaftStateMachine machine = new RaftStateMachine();
+ machine.addTaskHandler((op, response) -> {
+ if (op.getOp() == KVOperation.SAVE_SNAPSHOT) {
+ throw new PDException(Pdpb.ErrorType.ROCKSDB_SAVE_SNAPSHOT_ERROR_VALUE,
+ "checkpoint failed");
+ }
+ return false;
+ });
+ RecordingClosure done = new RecordingClosure();
+
+ machine.onSnapshotSave(this.writer, done);
+
+ Assert.assertTrue(done.await());
+ // Drain the job thread so a second completion, if any, has run
+ this.jobs.submit(() -> { }).get(10, TimeUnit.SECONDS);
+ Assert.assertEquals(1, done.statuses.size());
+ Assert.assertEquals(RaftError.EIO, done.statuses.get(0).getRaftError());
+ Assert.assertFalse(new File(this.snapshotPath, "snapshot.zip").exists());
+ }
+
+ @Test
+ public void testSnapshotWithAWrongChecksumIsNotReportedAsInstalled() throws Exception {
+ AtomicLong loads = new AtomicLong();
+ RaftStateMachine machine = this.machineThatCountsLoads(loads);
+ LocalFileMeta meta = this.saveSnapshot(machine);
+ LocalFileMeta wrong = LocalFileMeta.newBuilder().setChecksum("0").build();
+ Assert.assertNotEquals(wrong.getChecksum(), meta.getChecksum());
+
+ Assert.assertFalse(machine.onSnapshotLoad(this.readerWith(wrong)));
+
+ Assert.assertEquals(0L, loads.get());
+ }
+
+ @Test
+ public void testSnapshotThatIsNotAnArchiveIsNotReportedAsInstalled() throws Exception {
+ AtomicLong loads = new AtomicLong();
+ RaftStateMachine machine = this.machineThatCountsLoads(loads);
+
+ // No snapshot.zip in the snapshot directory
+ Assert.assertFalse(machine.onSnapshotLoad(
+ this.readerWith(LocalFileMeta.newBuilder().build())));
+
+ Assert.assertEquals(0L, loads.get());
+ }
+
+ @Test
+ public void testSnapshotWithItsChecksumIsInstalled() throws Exception {
+ AtomicLong loads = new AtomicLong();
+ RaftStateMachine machine = this.machineThatCountsLoads(loads);
+ LocalFileMeta meta = this.saveSnapshot(machine);
+
+ Assert.assertTrue(machine.onSnapshotLoad(this.readerWith(meta)));
+
+ Assert.assertEquals(1L, loads.get());
+ }
+
+ private RaftStateMachine machineThatCountsLoads(AtomicLong loads) {
+ RaftStateMachine machine = new RaftStateMachine();
+ machine.addTaskHandler((op, response) -> {
+ if (op.getOp() == KVOperation.SAVE_SNAPSHOT) {
+ writeCheckpoint((String) op.getAttach(), 10L);
+ } else if (op.getOp() == KVOperation.LOAD_SNAPSHOT) {
+ loads.incrementAndGet();
+ }
+ return false;
+ });
+ return machine;
+ }
+
+ /**
+ * Saves a snapshot into the snapshot directory and returns the file meta, with the
+ * checksum of the archive, that the state machine handed to jraft
+ */
+ private LocalFileMeta saveSnapshot(RaftStateMachine machine) throws Exception {
+ RecordingClosure done = new RecordingClosure();
+ machine.onSnapshotSave(this.writer, done);
+ Assert.assertTrue(done.await());
+ Assert.assertTrue(done.statuses.get(0).isOk());
+ ArgumentCaptor meta = ArgumentCaptor.forClass(Message.class);
+ verify(this.writer).addFile(eq("snapshot.zip"), meta.capture());
+ return (LocalFileMeta) meta.getValue();
+ }
+
+ private SnapshotReader readerWith(LocalFileMeta meta) {
+ SnapshotReader reader = mock(SnapshotReader.class);
+ when(reader.getPath()).thenReturn(this.snapshotPath.getAbsolutePath());
+ when(reader.getFileMeta("snapshot.zip")).thenReturn(meta);
+ return reader;
+ }
+
+ private static void writeCheckpoint(String dir, long index) {
+ try {
+ FileUtils.forceMkdir(new File(dir));
+ Files.write(new File(dir, "CURRENT").toPath(),
+ Long.toString(index).getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static final class RecordingClosure implements Closure {
+
+ private final List statuses = new CopyOnWriteArrayList<>();
+ private final CountDownLatch latch = new CountDownLatch(1);
+
+ @Override
+ public void run(Status status) {
+ this.statuses.add(status);
+ this.latch.countDown();
+ }
+
+ private boolean await() throws InterruptedException {
+ return this.latch.await(10, TimeUnit.SECONDS);
+ }
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/ZipUtilsTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/ZipUtilsTest.java
new file mode 100644
index 0000000000..c6a5f22a1f
--- /dev/null
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/ZipUtilsTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.pd.raft;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import com.alipay.sofa.jraft.util.CRC64;
+
+/**
+ * Covers the archive step of PD raft snapshots: RaftStateMachine compares the checksum
+ * computed while compressing with the one computed while decompressing.
+ */
+public class ZipUtilsTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ @Test
+ public void testCompressDecompressRoundTripWithNestedDirectories() throws IOException {
+ File root = tmp.newFolder("writer");
+ File snapshot = new File(root, "snapshot");
+ write(new File(snapshot, "CURRENT"), "MANIFEST-000001");
+ write(new File(snapshot, "sst/000001.sst"), "data-1");
+ write(new File(snapshot, "sst/deep/000002.sst"), "data-2");
+ File archive = new File(root, "snapshot.zip");
+
+ CRC64 compressSum = new CRC64();
+ ZipUtils.compress(root.getPath(), "snapshot", archive.getPath(), compressSum);
+ Assert.assertTrue(archive.length() > 0);
+
+ File reader = tmp.newFolder("reader");
+ CRC64 decompressSum = new CRC64();
+ ZipUtils.decompress(archive.getPath(), reader.getPath(), decompressSum);
+
+ Assert.assertEquals("MANIFEST-000001", read(new File(reader, "snapshot/CURRENT")));
+ Assert.assertEquals("data-1", read(new File(reader, "snapshot/sst/000001.sst")));
+ Assert.assertEquals("data-2", read(new File(reader, "snapshot/sst/deep/000002.sst")));
+ // The follower verifies the archive with the checksum the leader recorded
+ Assert.assertEquals(compressSum.getValue(), decompressSum.getValue());
+ }
+
+ @Test
+ public void testChecksumDiffersForDifferentContent() throws IOException {
+ Assert.assertNotEquals(checksumOf("data-1"), checksumOf("data-2"));
+ }
+
+ @Test
+ public void testCompressEmptyDirectoryProducesEmptyArchive() throws IOException {
+ File root = tmp.newFolder("empty-writer");
+ Assert.assertTrue(new File(root, "snapshot").mkdir());
+ File archive = new File(root, "snapshot.zip");
+
+ ZipUtils.compress(root.getPath(), "snapshot", archive.getPath(), new CRC64());
+
+ File reader = tmp.newFolder("empty-reader");
+ ZipUtils.decompress(archive.getPath(), reader.getPath(), new CRC64());
+ String[] extracted = reader.list();
+ Assert.assertNotNull(extracted);
+ Assert.assertEquals(0, extracted.length);
+ }
+
+ @Test
+ public void testDecompressSkipsEntriesEscapingOutputDir() throws IOException {
+ File base = tmp.newFolder("slip");
+ File output = new File(base, "out");
+ Assert.assertTrue(output.mkdir());
+ File archive = new File(base, "evil.zip");
+ try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(archive))) {
+ putEntry(zos, "../escaped.txt", "evil");
+ // A sibling whose name shares the output dir prefix is outside it as well
+ putEntry(zos, "../out-sibling/escaped.txt", "evil");
+ putEntry(zos, "snapshot/kept.txt", "good");
+ }
+
+ ZipUtils.decompress(archive.getPath(), output.getPath(), new CRC64());
+
+ Assert.assertFalse(new File(base, "escaped.txt").exists());
+ Assert.assertFalse(new File(base, "out-sibling").exists());
+ Assert.assertEquals("good", read(new File(output, "snapshot/kept.txt")));
+ }
+
+ @Test
+ public void testDecompressMissingArchiveFails() {
+ File missing = new File(tmp.getRoot(), "missing.zip");
+
+ Assert.assertThrows(IOException.class,
+ () -> ZipUtils.decompress(missing.getPath(), tmp.getRoot().getPath(),
+ new CRC64()));
+ }
+
+ private long checksumOf(String content) throws IOException {
+ File root = tmp.newFolder();
+ write(new File(root, "snapshot/file"), content);
+ CRC64 checksum = new CRC64();
+ ZipUtils.compress(root.getPath(), "snapshot", new File(root, "a.zip").getPath(),
+ checksum);
+ return checksum.getValue();
+ }
+
+ private static void putEntry(ZipOutputStream zos, String name, String content)
+ throws IOException {
+ zos.putNextEntry(new ZipEntry(name));
+ zos.write(content.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+
+ private static void write(File file, String content) throws IOException {
+ Files.createDirectories(file.getParentFile().toPath());
+ Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static String read(File file) throws IOException {
+ return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
+ }
+}
diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/LogServiceTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/LogServiceTest.java
index ba99ae6728..cdad8a6f32 100644
--- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/LogServiceTest.java
+++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/service/LogServiceTest.java
@@ -44,11 +44,11 @@ public void testGetLog() throws Exception {
logServiceUnderTest.insertLog("action", "message",
Any.newBuilder().build());
- // Run the test
+ // The query end is exclusive, so include logs inserted in the current millisecond.
final List result = logServiceUnderTest.getLog(
- "action", 0L, System.currentTimeMillis());
+ "action", 0L, System.currentTimeMillis() + 1L);
// Verify the results
- Assert.assertEquals(result.size(), 1);
+ Assert.assertEquals(1, result.size());
}
}
diff --git a/hugegraph-pd/pom.xml b/hugegraph-pd/pom.xml
index 83b39c8377..12c9500e5c 100644
--- a/hugegraph-pd/pom.xml
+++ b/hugegraph-pd/pom.xml
@@ -74,7 +74,6 @@
org.jacocojacoco-maven-plugin
- 0.8.8true
@@ -151,7 +150,6 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20pd-common-test
@@ -175,7 +173,6 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20pd-core-test
@@ -199,7 +196,6 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20pd-client-test
@@ -223,7 +219,6 @@
org.apache.maven.pluginsmaven-surefire-plugin
- 2.20pd-rest-test
diff --git a/hugegraph-server/Dockerfile b/hugegraph-server/Dockerfile
index 67866974cf..694acd6f21 100644
--- a/hugegraph-server/Dockerfile
+++ b/hugegraph-server/Dockerfile
@@ -18,7 +18,7 @@
# Dockerfile for HugeGraph Server
# 1st stage: build source code
-FROM --platform=$BUILDPLATFORM maven:3.9.0-eclipse-temurin-11 AS build
+FROM --platform=$BUILDPLATFORM maven:3.9.16-eclipse-temurin-17 AS build
WORKDIR /pkg
@@ -35,13 +35,13 @@ RUN --mount=type=cache,id=hugegraph-maven-${SOURCE_REVISION},target=/root/.m2,sh
# 2nd stage: runtime env
# Note: ZGC (The Z Garbage Collector) is only supported on ARM-Mac with java > 13
-FROM eclipse-temurin:11-jre-jammy
+FROM eclipse-temurin:17-jdk-jammy
+# Local Arthas attach requires the JDK attach and compiler modules.
LABEL maintainer="HugeGraph Docker Maintainers "
# TODO: use g1gc or zgc as default
-# Note: --add-exports is required for Java 11+ to access jdk.internal.reflect for auth proxy
-ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport -XX:MaxRAMPercentage=50 -XshowSettings:vm --add-exports=java.base/jdk.internal.reflect=ALL-UNNAMED" \
+ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport -XX:MaxRAMPercentage=50 -XshowSettings:vm" \
HUGEGRAPH_HOME="hugegraph-server" \
STDOUT_MODE="true"
diff --git a/hugegraph-server/Dockerfile-hstore b/hugegraph-server/Dockerfile-hstore
index e90979563b..351b116d6b 100644
--- a/hugegraph-server/Dockerfile-hstore
+++ b/hugegraph-server/Dockerfile-hstore
@@ -18,7 +18,7 @@
# Dockerfile for HugeGraph Server (hstore backend)
# 1st stage: build source code
-FROM --platform=$BUILDPLATFORM maven:3.9.0-eclipse-temurin-11 AS build
+FROM --platform=$BUILDPLATFORM maven:3.9.16-eclipse-temurin-17 AS build
WORKDIR /pkg
@@ -35,13 +35,15 @@ RUN --mount=type=cache,id=hugegraph-maven-${SOURCE_REVISION},target=/root/.m2,sh
# 2nd stage: runtime env
# Note: ZGC (The Z Garbage Collector) is only supported on ARM-Mac with java > 13
-FROM eclipse-temurin:11-jre-jammy
+FROM eclipse-temurin:17-jdk-jammy
+# Local Arthas attach requires the JDK attach and compiler modules.
LABEL maintainer="HugeGraph Docker Maintainers "
# TODO: use g1gc or zgc as default
ENV JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseContainerSupport -XX:MaxRAMPercentage=50 -XshowSettings:vm" \
- HUGEGRAPH_HOME="hugegraph-server"
+ HUGEGRAPH_HOME="hugegraph-server" \
+ STDOUT_MODE="true"
WORKDIR /hugegraph-server/
diff --git a/hugegraph-server/README.md b/hugegraph-server/README.md
index 819bece457..f559004bb7 100644
--- a/hugegraph-server/README.md
+++ b/hugegraph-server/README.md
@@ -1,15 +1,27 @@
# HugeGraph Server
+
+
HugeGraph Server consists of two layers of functionality: the graph engine layer, and the storage layer.
- Graph Engine Layer:
- REST Server: Provides a RESTful API for querying graph/schema information, supports the [Gremlin](https://tinkerpop.apache.org/gremlin.html) and [Cypher](https://en.wikipedia.org/wiki/Cypher) query languages, and offers APIs for service monitoring and operations.
+ - See [Cypher HTTP contracts](../docs/server-runtime.md#cypher-http-contracts) for request forms, verified behavior, and
+ unverified scope.
- Graph Engine: Supports both OLTP and OLAP graph computation types, with OLTP implementing the [Apache TinkerPop3](https://tinkerpop.apache.org) framework.
- Backend Interface: Implements the storage of graph data to the backend.
- Storage Layer:
- Storage Backend: Includes RocksDB (default, embedded), HStore (distributed), HBase (deprecated and planned for removal in 2.0), and the test-only Memory backend. Users can extend custom backends without modifying the existing source code.
+See [launcher GC options](../docs/server-runtime.md#launcher-gc-options) for explicit collector selection
+and caller JVM tuning.
+See [cache RPC graph lifecycle](../docs/server-runtime.md#cache-rpc-graph-lifecycle) for dynamic
+graph registration, resource cleanup and notification delivery limits.
+See [local loading ownership](../docs/server-runtime.md#local-loading-ownership) for
+concurrent import mode protection and its compatibility limits.
+See [pairwise same neighbors](../docs/same-neighbors-batch.md) for the batch traverser request and response.
+
## Backend Evolution and Compatibility
The current mainline does not include implementations for the historical backends. The following timeline distinguishes current support from legacy compatibility guidance:
diff --git a/hugegraph-server/hugegraph-api/pom.xml b/hugegraph-server/hugegraph-api/pom.xml
index e5a81be208..c6aeffa306 100644
--- a/hugegraph-server/hugegraph-api/pom.xml
+++ b/hugegraph-server/hugegraph-api/pom.xml
@@ -44,9 +44,7 @@
io.grpcgrpc-netty-shaded
-
-
- 1.47.0
+ ${grpc.version}
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java
index b50195b85d..b5653d5977 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/API.java
@@ -24,7 +24,7 @@
import java.util.function.Consumer;
import org.apache.commons.lang.mutable.MutableLong;
-import org.apache.hugegraph.HugeException;
+import org.apache.hugegraph.exception.HugeException;
import org.apache.hugegraph.HugeGraph;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.define.Checkable;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/arthas/ArthasAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/arthas/ArthasAPI.java
index 7cd9a98f8a..f2dd9e9809 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/arthas/ArthasAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/arthas/ArthasAPI.java
@@ -17,45 +17,109 @@
package org.apache.hugegraph.api.arthas;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
import java.util.HashMap;
import org.apache.hugegraph.api.API;
+import org.apache.hugegraph.auth.HugeAuthenticator;
+import org.apache.hugegraph.config.ConfigOption;
import org.apache.hugegraph.config.HugeConfig;
import org.apache.hugegraph.config.ServerOptions;
+import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.util.JsonUtil;
+import org.apache.hugegraph.util.Log;
+import org.slf4j.Logger;
import com.codahale.metrics.annotation.Timed;
import com.taobao.arthas.agent.attach.ArthasAgent;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Singleton;
+import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.SecurityContext;
@Path("arthas")
@Singleton
@Tag(name = "ArthasAPI")
public class ArthasAPI extends API {
+ private static final Logger LOG = Log.logger(ArthasAPI.class);
+
@Context
private jakarta.inject.Provider configProvider;
@PUT
@Timed
@Produces(APPLICATION_JSON_WITH_CHARSET)
+ @RolesAllowed("admin")
@Operation(summary = "start arthas agent")
- public Object startArthas() {
+ public Object startArthas(@Context GraphManager manager,
+ @Context SecurityContext context) {
+ checkAccess(manager, context);
HugeConfig config = this.configProvider.get();
+ HashMap configMap = arthasConfig(config);
+ String response = JsonUtil.toJson(configMap);
+ // Keep the optional MCP endpoint disabled without extending the activation response.
+ configMap.put("arthas.mcpEndpoint", "");
+ // Do not let external Arthas properties override the configured listener.
+ configMap.put("arthas.config.overrideAll", "false");
+ configMap.put("arthas.tunnelServer", "");
+ ArthasAgent.attach(configMap);
+ return response;
+ }
+
+ static void checkAccess(GraphManager manager, SecurityContext context) {
+ // No-auth requests receive an anonymous admin role, so a role check alone is insufficient.
+ if (!manager.requireAuthentication()) {
+ throw new ForbiddenException("HTTP Arthas activation requires authentication; " +
+ "use bin/arthas.sh on the server host instead");
+ }
+ if (context == null || context.getUserPrincipal() == null ||
+ HugeAuthenticator.USER_ANONY.equals(context.getUserPrincipal().getName()) ||
+ !context.isUserInRole("admin")) {
+ throw new ForbiddenException("Arthas activation requires an authenticated administrator");
+ }
+ }
+
+ static HashMap arthasConfig(HugeConfig config) {
HashMap configMap = new HashMap<>(4);
- configMap.put("arthas.telnetPort", config.get(ServerOptions.ARTHAS_TELNET_PORT));
- configMap.put("arthas.httpPort", config.get(ServerOptions.ARTHAS_HTTP_PORT));
- configMap.put("arthas.ip", config.get(ServerOptions.ARTHAS_IP));
+ configMap.put("arthas.telnetPort",
+ option(config, ServerOptions.ARTHAS_TELNET_PORT,
+ ServerOptions.ARTHAS_TELNET_PORT_LEGACY));
+ configMap.put("arthas.httpPort",
+ option(config, ServerOptions.ARTHAS_HTTP_PORT,
+ ServerOptions.ARTHAS_HTTP_PORT_LEGACY));
+ String ip = config.get(ServerOptions.ARTHAS_IP);
+ ip = "localhost".equalsIgnoreCase(ip) ? "127.0.0.1" : ip;
+ try {
+ InetAddress.getByName(ip);
+ } catch (UnknownHostException e) {
+ throw new IllegalArgumentException("Invalid or unresolvable Arthas bind address '" + ip + "'", e);
+ }
+ configMap.put("arthas.ip", ip);
configMap.put("arthas.disabledCommands",
- config.get(ServerOptions.ARTHAS_DISABLED_COMMANDS));
- ArthasAgent.attach(configMap);
- return JsonUtil.toJson(configMap);
+ option(config, ServerOptions.ARTHAS_DISABLED_COMMANDS,
+ ServerOptions.ARTHAS_DISABLED_COMMANDS_LEGACY));
+ return configMap;
+ }
+
+ private static String option(HugeConfig config, ConfigOption canonical,
+ ConfigOption legacy) {
+ if (!config.containsKey(canonical.name())) {
+ return config.get(legacy);
+ }
+ String value = config.get(canonical);
+ if (config.containsKey(legacy.name()) && !value.equals(config.get(legacy))) {
+ LOG.warn("Conflicting Arthas options '{}' and '{}': '{}' takes precedence",
+ canonical.name(), legacy.name(), canonical.name());
+ }
+ return value;
}
}
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/AccessAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/AccessAPI.java
index 117da8e9ae..578685feb1 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/AccessAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/AccessAPI.java
@@ -26,7 +26,7 @@
import org.apache.hugegraph.auth.HugeAccess;
import org.apache.hugegraph.auth.HugePermission;
import org.apache.hugegraph.auth.HugeTarget;
-import org.apache.hugegraph.backend.id.Id;
+import org.apache.hugegraph.id.Id;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.exception.NotFoundException;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/BelongAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/BelongAPI.java
index d5e3f105bb..5f2d6d5663 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/BelongAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/BelongAPI.java
@@ -24,7 +24,7 @@
import org.apache.hugegraph.api.filter.StatusFilter.Status;
import org.apache.hugegraph.auth.AuthManager;
import org.apache.hugegraph.auth.HugeBelong;
-import org.apache.hugegraph.backend.id.Id;
+import org.apache.hugegraph.id.Id;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.exception.NotFoundException;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GraphSpaceGroupAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GraphSpaceGroupAPI.java
index d41de962e2..eb3b7c1ec2 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GraphSpaceGroupAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GraphSpaceGroupAPI.java
@@ -29,7 +29,7 @@
import org.apache.hugegraph.auth.HugeGroup;
import org.apache.hugegraph.auth.HugeUser;
import org.apache.hugegraph.auth.StandardAuthManagerV2;
-import org.apache.hugegraph.backend.id.Id;
+import org.apache.hugegraph.id.Id;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.exception.NotFoundException;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java
index ae13beb4a6..f19200c65a 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/GroupAPI.java
@@ -22,7 +22,7 @@
import org.apache.hugegraph.api.API;
import org.apache.hugegraph.api.filter.StatusFilter.Status;
import org.apache.hugegraph.auth.HugeGroup;
-import org.apache.hugegraph.backend.id.IdGenerator;
+import org.apache.hugegraph.id.IdGenerator;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.exception.NotFoundException;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/ProjectAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/ProjectAPI.java
index 4380093ba0..322d434bff 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/ProjectAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/ProjectAPI.java
@@ -27,7 +27,7 @@
import org.apache.hugegraph.api.filter.StatusFilter.Status;
import org.apache.hugegraph.auth.AuthManager;
import org.apache.hugegraph.auth.HugeProject;
-import org.apache.hugegraph.backend.id.Id;
+import org.apache.hugegraph.id.Id;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.exception.NotFoundException;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/UserAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/UserAPI.java
index 7504ad7325..d5e5288f49 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/UserAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/auth/UserAPI.java
@@ -23,8 +23,8 @@
import org.apache.hugegraph.api.API;
import org.apache.hugegraph.api.filter.StatusFilter.Status;
import org.apache.hugegraph.auth.HugeUser;
-import org.apache.hugegraph.backend.id.Id;
-import org.apache.hugegraph.backend.id.IdGenerator;
+import org.apache.hugegraph.id.Id;
+import org.apache.hugegraph.id.IdGenerator;
import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.define.Checkable;
import org.apache.hugegraph.exception.NotFoundException;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java
index a30bff73b8..7bac7e18c0 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherAPI.java
@@ -20,6 +20,7 @@
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
+import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
@@ -27,13 +28,22 @@
import org.apache.commons.lang3.tuple.Pair;
import org.apache.hugegraph.api.API;
import org.apache.hugegraph.api.filter.CompressInterceptor;
+import org.apache.hugegraph.core.GraphManager;
import org.apache.hugegraph.util.E;
import org.apache.hugegraph.util.Log;
+import org.apache.tinkerpop.shaded.jackson.core.JsonProcessingException;
+import org.apache.tinkerpop.shaded.jackson.databind.DeserializationFeature;
+import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper;
+import org.apache.tinkerpop.shaded.jackson.databind.ObjectReader;
import org.slf4j.Logger;
import com.codahale.metrics.annotation.Timed;
import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.inject.Singleton;
import jakarta.ws.rs.Consumes;
@@ -55,7 +65,10 @@ public class CypherAPI extends API {
private static final Logger LOG = Log.logger(CypherAPI.class);
private static final Charset UTF8 = StandardCharsets.UTF_8;
private static final String CLIENT_CONF = "conf/remote-objects.yaml";
- private final Base64.Decoder decoder = Base64.getUrlDecoder();
+ private static final ObjectReader REQUEST_READER = new ObjectMapper()
+ .readerFor(Object.class)
+ .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ private final Base64.Decoder decoder = Base64.getDecoder();
private final String basic = "Basic ";
private final String bearer = "Bearer ";
@@ -72,7 +85,8 @@ private CypherManager cypherManager() {
@Timed
@CompressInterceptor.Compress(buffer = (1024 * 40))
@Produces(APPLICATION_JSON_WITH_CHARSET)
- public CypherModel query(@Context HttpHeaders headers,
+ public CypherModel query(@Context GraphManager manager,
+ @Context HttpHeaders headers,
@Parameter(description = "The graph space name")
@PathParam("graphspace") String graphspace,
@Parameter(description = "The graph name")
@@ -80,7 +94,8 @@ public CypherModel query(@Context HttpHeaders headers,
@Parameter(description = "The cypher query string")
@QueryParam("cypher") String cypher) {
- return this.queryByCypher(headers, graphspace, graph, cypher);
+ return this.queryByCypher(manager, headers, graphspace, graph, cypher,
+ Collections.emptyMap());
}
@@ -89,35 +104,89 @@ public CypherModel query(@Context HttpHeaders headers,
@CompressInterceptor.Compress
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON_WITH_CHARSET)
- public CypherModel post(@Context HttpHeaders headers,
+ @RequestBody(required = true,
+ description = "A nonblank raw Cypher query, or a JSON object with a nonblank " +
+ "string 'cypher' and optional object 'parameters'. Omitted " +
+ "parameters default to an empty object; null, arrays and " +
+ "scalars are rejected. The JSON request itself must be an object. " +
+ "Legacy raw Cypher sent as application/json " +
+ "is also accepted.",
+ content = {
+ @Content(mediaType = APPLICATION_JSON,
+ schema = @Schema(implementation = Map.class),
+ examples = @ExampleObject(name = "Parameterized query",
+ value = "{\"cypher\":\"MATCH (n:person) WHERE " +
+ "n.name = $name RETURN n.name\"," +
+ "\"parameters\":{\"name\":\"marko\"}}"))
+ })
+ public CypherModel post(@Context GraphManager manager,
+ @Context HttpHeaders headers,
@Parameter(description = "The graph space name")
@PathParam("graphspace") String graphspace,
@Parameter(description = "The graph name")
@PathParam("graph") String graph,
- @Parameter(description = "The cypher query string")
String cypher) {
- return this.queryByCypher(headers, graphspace, graph, cypher);
+ Map parameters = Collections.emptyMap();
+ if (looksLikeJson(cypher)) {
+ Object value;
+ try {
+ value = REQUEST_READER.readValue(cypher);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Invalid Cypher request JSON", e);
+ }
+ E.checkArgument(value instanceof Map, "The Cypher request must be a JSON object");
+ Map, ?> request = (Map, ?>) value;
+ Object query = request.get("cypher");
+ E.checkArgument(query instanceof String,
+ "The cypher parameter must be a nonblank string");
+ cypher = (String) query;
+ if (request.containsKey("parameters")) {
+ Object bindings = request.get("parameters");
+ E.checkArgument(bindings instanceof Map,
+ "The parameters parameter must be an object");
+ @SuppressWarnings("unchecked")
+ Map supplied = (Map) bindings;
+ parameters = supplied;
+ }
+ }
+ return this.queryByCypher(manager, headers, graphspace, graph, cypher, parameters);
+ }
+
+ private static boolean looksLikeJson(String body) {
+ if (body == null || body.isBlank()) {
+ return false;
+ }
+ String value = body.strip();
+ char first = value.charAt(0);
+ return first == '{' || first == '[' || first == '"' || first == '-' ||
+ (first >= '0' && first <= '9') || value.equals("null") ||
+ value.equals("true") || value.equals("false");
}
- private CypherModel queryByCypher(HttpHeaders headers, String graphspace,
- String graph, String cypher) {
+ private CypherModel queryByCypher(GraphManager manager, HttpHeaders headers, String graphspace,
+ String graph, String cypher,
+ Map parameters) {
E.checkArgument(graphspace != null && !graphspace.isEmpty(),
"The graphspace parameter can't be null or empty");
E.checkArgument(graph != null && !graph.isEmpty(),
"The graph parameter can't be null or empty");
- E.checkArgument(cypher != null && !cypher.isEmpty(),
- "The cypher parameter can't be null or empty");
+ E.checkArgument(cypher != null && !cypher.isBlank(),
+ "The cypher parameter must be a nonblank string");
String graphInfo = graphspace + "-" + graph;
Map aliases = new HashMap<>(2, 1);
aliases.put("graph", graphInfo);
aliases.put("g", "__g_" + graphInfo);
- return this.client(headers).submitQuery(cypher, aliases);
+ return this.client(manager, headers).submitQuery(cypher, aliases, parameters);
}
- private CypherClient client(HttpHeaders headers) {
+ private CypherClient client(GraphManager manager, HttpHeaders headers) {
+ if (!manager.requireAuthentication()) {
+ return this.cypherManager().getAnonymousClient();
+ }
+
String auth = headers.getHeaderString(HttpHeaders.AUTHORIZATION);
if (auth != null && !auth.isEmpty()) {
@@ -159,7 +228,7 @@ private Pair toUserPass(String auth) {
String encoded = auth.substring(basic.length());
byte[] userPass = this.decoder.decode(encoded);
String authorization = new String(userPass, UTF8);
- split = authorization.split(":");
+ split = authorization.split(":", 2);
} catch (Exception e) {
LOG.error("Failed convert auth to credential.", e);
return null;
diff --git a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java
index 92ae18c54d..c9b19c71ac 100644
--- a/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java
+++ b/hugegraph-server/hugegraph-api/src/main/java/org/apache/hugegraph/api/cypher/CypherClient.java
@@ -17,7 +17,11 @@
package org.apache.hugegraph.api.cypher;
+import java.lang.reflect.Array;
+import java.util.Collections;
+import java.util.IdentityHashMap;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@@ -29,25 +33,32 @@
import javax.annotation.concurrent.ThreadSafe;
import org.apache.commons.configuration2.Configuration;
+import org.apache.hugegraph.id.Id;
import org.apache.hugegraph.util.E;
import org.apache.hugegraph.util.Log;
import org.apache.tinkerpop.gremlin.driver.Client;
import org.apache.tinkerpop.gremlin.driver.Cluster;
import org.apache.tinkerpop.gremlin.driver.Result;
import org.apache.tinkerpop.gremlin.driver.ResultSet;
-import org.apache.tinkerpop.gremlin.driver.Tokens;
-import org.apache.tinkerpop.gremlin.driver.message.RequestMessage;
+import org.apache.tinkerpop.gremlin.process.traversal.Path;
+import org.apache.tinkerpop.gremlin.util.Tokens;
+import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
import org.slf4j.Logger;
@ThreadSafe
public final class CypherClient {
private static final Logger LOG = Log.logger(CypherClient.class);
+ private static final int NORMALIZE_MAX_DEPTH = 32;
private final Supplier configurationSupplier;
private String userName;
private String password;
private String token;
+ CypherClient(Supplier configurationSupplier) {
+ this.configurationSupplier = configurationSupplier;
+ }
+
CypherClient(String userName, String password,
Supplier configurationSupplier) {
this.userName = userName;
@@ -61,38 +72,50 @@ public final class CypherClient {
}
public CypherModel submitQuery(String cypherQuery, @Nullable Map aliases) {
- E.checkArgument(cypherQuery != null && !cypherQuery.isEmpty(),
- "The cypher-query parameter can't be null or empty");
+ return this.submitQuery(cypherQuery, aliases, Collections.emptyMap());
+ }
- Cluster cluster = Cluster.open(getConfig());
- Client client = cluster.connect();
+ public CypherModel submitQuery(String cypherQuery, @Nullable Map aliases,
+ Map parameters) {
+ E.checkArgument(cypherQuery != null && !cypherQuery.isBlank(),
+ "The cypher-query parameter must be a nonblank string");
+ E.checkArgument(parameters != null, "The parameters parameter must be an object");
- if (aliases != null && !aliases.isEmpty()) {
- client = client.alias(aliases);
- }
-
- RequestMessage request = createRequest(cypherQuery);
+ RequestMessage request = createRequest(cypherQuery, parameters);
+ Cluster cluster = Cluster.open(getConfig());
+ Client client = null;
CypherModel res;
try {
+ client = cluster.connect();
+ if (aliases != null && !aliases.isEmpty()) {
+ client = client.alias(aliases);
+ }
List